文中实现的局部工具办法正处于晚期/测试阶段,仍在继续优化中,仅供参考...

>>博客原文连贯

Contents


├── Contents (you are here!)|├── electron-re 能够用来做什么?│   ├── 1)用于Electron利用|   └── 2)用于Electron/Nodejs利用|├── 阐明1:Service/MessageChannel│   ├── Service的创立│   ├── Service的主动刷新│   ├── MessageChannel的引入│   ├── MessageChannel提供的办法│   └── 比照MessageChannel和原生ipc通信的应用|       ├── 1)应用remote近程调用(原生)|       ├── 2)应用ipc信号通信(原生)|       └── 3)应用MessageChannel进行多向通信(扩大)|├── 阐明2:ChildProcessPool/ProcessHost│   ├── 过程池的创立│   ├── 过程池的实例办法│   ├── 子过程事务核心│   └── 过程池和子过程事务核心的配合应用|       ├── 1)主过程中应用过程池向子过程发送申请|       └── 2)子过程中用事务核心解决音讯|├── Next To Do|├── 几个理论应用实例│   ├── 1)Service/MessageChannel示例│   ├── 2)ChildProcessPool/ProcessHost示例│   └── 3)test测试目录示例

I. 前言


最近在做一个多文件分片并行上传模块的时候(基于Electron和React),遇到了一些性能问题,次要体现在:前端同时增加大量文件(1000-10000)并行上传时(文件同时上传数默认为6),在不做懒加载优化的状况下,引起了整个利用窗口的卡顿。所以针对Electron/Nodejs多过程这方面做了一些学习,尝试应用多过程架构对上传流程进行优化。

同时也编写了一个不便进行Electron/Node多过程治理和调用的工具electron-re,曾经公布为npm组件,能够间接装置:

$: npm install electron-re --save# or$: yarn add electron-re

如果感兴趣是怎么一步一步解决性能问题的话能够查看这篇文章:《基于Electron的smb客户端文件上传优化摸索》。

上面来讲讲配角=> electron-re

II. electron-re 能够用来做什么?


1. 用于Electron利用

  • BrowserService
  • MessageChannel

在Electron的一些“最佳实际”中,倡议将占用cpu的代码放到渲染过程中而不是间接放在主过程中,这里先看下chromium的架构图:

每个渲染过程都有一个全局对象RenderProcess,用来治理与父浏览器过程的通信,同时保护着一份全局状态。浏览器过程为每个渲染过程保护一个RenderProcessHost对象,用来治理浏览器状态和与渲染过程的通信。浏览器过程和渲染过程应用Chromium的IPC零碎进行通信。在chromium中,页面渲染时,UI过程须要和main process一直的进行IPC同步,若此时main process忙,则UIprocess就会在IPC时阻塞。所以如果主过程继续进行耗费CPU工夫的工作或阻塞同步IO的工作的话,就会在肯定水平上阻塞,从而影响主过程和各个渲染过程之间的IPC通信,IPC通信有提早或是碰壁,渲染过程窗口就会卡顿掉帧,重大的话甚至会卡住不动。

因而electron-re在Electron已有的Main Process主过程和Renderer Process渲染过程逻辑的根底上独立出一个独自的Service概念。Service即不须要显示界面的后盾过程,它不参加UI交互,独自为主过程或其它渲染过程提供服务,它的底层实现为一个容许node注入remote调用的渲染窗口过程。

这样就能够将代码中消耗cpu的操作(比方文件上传中保护一个数千个上传工作的队列)编写成一个独自的js文件,而后应用BrowserService构造函数以这个js文件的地址path为参数结构一个Service实例,从而将他们从主过程中拆散。如果你说那这部分消耗cpu的操作间接放到渲染窗口过程能够嘛?这其实取决于我的项目本身的架构设计,以及对过程之间数据传输性能损耗和传输工夫等各方面的衡量,创立一个Service的简略示例:

const { BrowserService } = require('electron-re');const myServcie = new BrowserService('app', path.join(__dirname, 'path/to/app.service.js'));

如果应用了BrowserService的话,要想在主过程、渲染过程、service过程之间任意发送音讯就要应用electron-re提供的MessageChannel通信工具,它的接口设计跟Electron内建的ipc基本一致,也是基于ipc通信原理来实现的,简略示例如下:

/* ---- main.js ---- */const { BrowserService } = require('electron-re');// 主过程中向一个service-app发送音讯MessageChannel.send('app', 'channel1', { value: 'test1' });

2. 用于Electron/Nodejs利用

  • ChildProcessPool
  • ProcessHost

此外,如果要创立一些不依赖于Electron运行时的子过程(相干参考nodejs child_process),能够应用electron-re提供的专门为nodejs运行时编写的过程池ChildProcessPool类。因为创立过程自身所需的开销很大,应用过程池来反复利用曾经创立了的子过程,将多过程架构带来的性能效益最大化,简略实例如下:

const { ChildProcessPool } = require('electron-re');global.ipcUploadProcess = new ChildProcessPool({  path: path.join(app.getAppPath(), 'app/services/child/upload.js'), max: 6});

个别状况下,在咱们的子过程执行文件中(创立子过程时path参数指定的脚本),如要想在主过程和子过程之间同步数据,能够应用process.send('channel', params)process.on('channel', function)来实现。然而这样在解决业务逻辑的同时也强制咱们去关注过程之间的通信,你须要晓得子过程什么时候能处理完毕,而后再应用process.send再将数据返回主过程,应用形式繁琐。

electron-re引入了ProcessHost的概念,我将它称之为"过程事务核心"。理论应用时在子过程执行文件中只须要将各个工作函数通过ProcessHost.registry('task-name', function)注册成多个被监听的事务,而后配合过程池的ChildProcessPool.send('task-name', params)来触发子过程的事务逻辑的调用即可,ChildProcessPool.send()同时会返回一个Promise实例以便获取回调数据,简略示例如下:

/* --- 主过程中 --- */...global.ipcUploadProcess.send('task1', params);/* --- 子过程中 --- */const { ProcessHost } = require('electron-re');ProcessHost    .registry('task1', (params) => {      return { value: 'task-value' };    })    .registry('init-works', (params) => {      return fetch(url);    });

III. Service/MessageChannel


用于Electron利用中 - Service过程拆散/过程间通信

BrowserService的创立

须要期待app触发ready事件后能力开始创立Service,创立后如果立刻向Service发送申请可能接管不到,须要调用service.connected()异步办法来期待Service筹备实现,反对Promise写法。

Electron主过程main.js文件中:

/* --- in electron main.js entry --- */const { app } = require('electron');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 processapp.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();    ...});

BrowserService的主动刷新

反对Service代码文件更新后主动刷新Service,简略设置两个配置项即可。

1.须要申明以后运行环境为开发环境
2.创立Service时禁用web安全策略

const myService = new BrowserService('app', 'path/to/app.service.js', {  ...options,  // 设置开发模式  dev: true,  // 敞开安全策略  webPreferences: { webSecurity: false }});

MessageChannel的引入

留神必须在main.js中引入,引入后会主动进行初始化。

MessageChannel在主过程/Service/渲染过程窗口中的应用形式基本一致,具体请参考下文"比照MessageChannel和原生ipc通信的应用"。

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

MessageChannel提供的办法

1.公共办法,实用于 - 主过程/渲染过程/Service

/* 向一个Service发送申请 */MessageChannel.send('service-name', channel, params);/* 向一个Servcie发送申请,并获得Promise实例 */MessageChannel.invoke('service-name', channel, params);/* 依据windowId/webContentsId,向渲染过程发送申请 */MessageChannel.sendTo('windowId/webContentsId', channel, params);/* 监听一个信号 */MessageChannel.on(channel, func);/* 监听一次信号 */MessageChannel.once(channel, func);

2.仅实用于 - 渲染过程/Service

/* 向主过程发送音讯 */MessageChannel.send('main', channel, params);/* 向主过程发送音讯,并获得Promise实例 */MessageChannel.invoke('main', channel, params);

3.仅实用于 - 主过程/Service

/*   监听一个信号,调用处理函数,  能够在处理函数中返回一个异步的Promise实例或间接返回数据*/MessageChannel.handle(channel, processorFunc);

比照MessageChannel和原生ipc通信的应用

1/2 - 原生办法,3 - 扩大办法

1.应用remote近程调用

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

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

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

2.应用ipc信号通信

基于事件触发的ipc双向信号通信,渲染过程中的ipcRenderer能够监听一个事件通道,也能向主过程或其它渲染过程间接发送音讯(须要晓得其它渲染过程的webContentsId),同理主过程中的ipcMain也能监听某个事件通道和向任意一个渲染过程发送音讯。
Electron过程之间通信最罕用的一系列办法,然而在向其它子过程发送音讯之前须要晓得指标过程的webContentsId或者可能间接拿到指标过程的实例,应用形式不太灵便。

/* 主过程 */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.应用MessageChannel进行多向通信

  • 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 processapp.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();    MessageChannel.send('app', 'channel1', { value: 'test1' });    MessageChannel.invoke('app', 'channel2', { value: 'test2' }).then((response) => {      console.log(response);    });    MessageChannel.on('channel3', (event, response) => {      console.log(response);    });    MessageChannel.handle('channel4', (event, response) => {      console.log(response);      return { res: 'channel4-res' };    });});
  • 2)app.service.js - 在一个service中
const { ipcRenderer } = require('electron');const { MessageChannel } = require('electron-re');MessageChannel.on('channel1', (event, result) => {  console.log(result);});MessageChannel.handle('channel2', (event, result) => {  console.log(result);  return { response: 'channel2-response' }});MessageChannel.invoke('app2', 'channel3', { value: 'channel3' }).then((event, result) => {  console.log(result);});MessageChannel.send('app', 'channel4', { value: 'channel4' });
  • 3)app2.service.js - 在另一个service中
MessageChannel.handle('channel3', (event, result) => {  console.log(result);  return { response: 'channel3-response' }});MessageChannel.once('channel4', (event, result) => {  console.log(result);});MessageChannel.send('main', 'channel3', { value: 'channel3' });MessageChannel.invoke('main', 'channel4', { value: 'channel4' });
  • 4)renderer process window - 在一个渲染窗口中
const { ipcRenderer } = require('electron');const { MessageChannel } = require('electron-re');MessageChannel.send('app', 'channel1', { value: 'test1'});MessageChannel.invoke('app2', 'channel3', { value: 'test2' });MessageChannel.send('main', 'channel3', { value: 'test3' });MessageChannel.invoke('main', 'channel4', { value: 'test4' });

IV. ChildProcessPool/ProcessHost


用于Electron和Nodejs利用中 - Node.js过程池/子过程事务核心

过程池的创立

过程池基于nodejs的child_process模块,应用fork形式创立并治理多个独立的子过程。

创立过程池时提供最大子过程实例个数子过程执行文件门路等参数即可,过程会主动接管过程的创立和调用。内部能够通过过程池向某个子过程发送申请,而在过程池外部其实就是依照程序顺次将曾经创立的多个子过程中的某一个返回给内部调用即可,从而防止了其中某个过程被适度应用。

子过程是通过懒加载形式创立的,也就是说如果只创立过程池而不对过程池发动申请调用的话,过程池将不会创立任何子过程实例。

1.参数阐明

|—— path 参数为可执行文件门路|—— max 指明过程池创立的最大子过程实例数量|—— env 为传递给子过程的环境变量

2.主过程中引入过程池类,并创立过程池实例

/* main.js */...const ChildProcessPool = require('path/to/ChildProcessPool.class');const processPool = new ChildProcessPool({  path: path.join(app.getAppPath(), 'app/services/child/upload.js'),  max: 3,  env: { lang: global.lang }});...

过程池的实例办法

留神task-name即一个子过程注册的工作名,指向子过程的某个函数,具体请查看下体面过程事务核心的阐明

1.processPool.send('task-name', params, id)

向某个子过程发送音讯,如果申请参数指定了id则表明须要应用之前与此id建设过映射的某个过程(id将在send调用之后主动绑定),并冀望拿到此过程的回应后果。

id的应用状况比方:我第一次调用过程池在一个子过程里设置了一些数据(子过程之间数据不共享),第二次时想拿到之前设置的那个数据,这时候只有放弃两次send()申请携带的id统一即可,否则将不能保障两次申请发送给了同一个子过程。

/**  * send [Send request to a process]  * @param  {[String]} taskName [task name - necessary]  * @param  {[Any]} params [data passed to process - necessary]  * @param  {[String]} id [the unique id bound to a process instance - not necessary]  * @return {[Promise]} [return a Promise instance]  */ send(taskName, params, givenId) {...}

2.processPool.sendToAll('task-name', params)

向过程池中的所有过程发送信号,并冀望拿到所有过程返回的后果,返回的数据为一个数组。

  /**  * sendToAll [Send requests to all processes]  * @param  {[String]} taskName [task name - necessary]  * @param  {[Any]} params [data passed to process - necessary]  * @return {[Promise]} [return a Promise instance]  */  sendToAll(taskName, params) {...}

3.processPool.disconnect(id)

销毁过程池的子过程,如果不指定id调用的话就会销毁所有子过程,指定id参数能够独自销毁与此id值绑定过的某个子过程,销毁后再次调用过程池发送申请时会主动创立新的子过程。

须要留神的是id绑定操作是在processPool.send('task-name', params, id)办法调用后主动进行的。

4.processPool.setMaxInstanceLimit(number)

除了在创立过程池时应用max参数指定最大子过程实例个数,也能调用过程池的此办法来动静设置须要创立的子过程实例个数。

子过程事务核心

ProcessHost - 子过程事务核心,须要和ChildProcessPool协同工作,用来分离子过程通信逻辑和业务逻辑,优化子过程代码构造。

次要性能是应用api诸如 - ProcessHost.registry(taskName, func)来注册多种工作,而后在主过程中能够间接应用过程池向某个工作发送申请并获得Promise对象以拿到过程回调返回的数据,从而防止在咱们的子过程执行文件中编写代码时适度关注过程之间数据的通信。
如果不应用过程事务管理中心的话咱们就须要应用process.send来向一个过程发送音讯并在另一个过程中应用process.on('message', processor)解决音讯。须要留神的是如果注册的task工作是异步的则须要返回一个Promise对象而不是间接return数据,实例办法如下:

  • 1)registry用于子过程向事务核心注册本人的工作(反对链式调用)
  • 2)unregistry用于勾销工作注册(反对链式调用)

应用阐明:

/* in child process */const { ProcessHost } = require('electron-re');ProcessHost  .registry('test1', (params) => {    return params;  })  .registry('test2', (params) => {    return fetch(url);  });ProcessHost  .unregistry('test1')  .unregistry('test2');

过程池和子过程事务核心的配合应用

示例:文件分片上传中,主过程中应用过程池来发送初始化分片上传申请,子过程拿到申请信号处理业务而后返回

1.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 processPool.send(            /* 过程事务名 */            'init-works',            /* 携带的参数 */            {              username, host, sharename, pre, prefix,              size: file.size, name: file.name, abspath, fragsize            },            /* 指定一个过程调用id */            uploadId          )        })      .then((rsp) => {        resolve({          code: rsp.error ? 600 : 200,          result: rsp.result,        });      }).catch(err => {        resolve({          code: 600,          result: err.toString()        });      });    });  }

2.child.js (in child process)中应用事务管理中心解决音讯

child.js即为创立过程池时传入的path参数所在的nodejs脚本代码,在此脚本中咱们注册多个工作来解决从过程池发送过去的音讯

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

  const fs = require('fs');  const path = require('path');  const utils = require('./child.utils');  const { readFileBlock, uploadRecordStore, unlink } = utils;  const { ProcessHost } = require('electron-re');  // 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);    })    ...  /* *************** upload logic *************** */  /* 上传初始化工作 */  function initWorks({username, host, sharename, pre, prefix, name, abspath, size, fragsize }) {    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 = {            ...          };          uploadStore.set(newRecord);          return newRecord;        } else {          throw new Error(rsp.result);        }     })     .then(resolve)     .catch(error => {      reject(error.toString());     });    })  }  /* 上传分片 */  function uplaodWorks(){ ... };  ...

V. Next To Do


  • [✓] 让Service反对代码更新后主动重启
  • [x] 增加ChildProcessPool子过程调度逻辑
  • [x] 优化ChildProcessPool多过程console输入
  • [x] 加强ChildProcessPool过程池性能
  • [x] 加强ProcessHost事务核心性能

VI. 一些理论应用示例


  1. electronux - 我的一个Electron我的项目,应用了 BrowserService and MessageChannel
  2. file-slice-upload - 一个对于多文件分片并行上传的demo,应用了 ChildProcessPool and ProcessHost,基于 Electron@9.3.5。
  3. 查看 test 目录下的测试样例文件,蕴含了残缺的细节应用。