关于electron:ElectronNode多进程工具开发日记

16次阅读

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

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

>> 博客原文连贯

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 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();
    ...
});

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').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 也能监听某个事件通道和向任意一个渲染过程发送音讯。
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 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();
    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 目录下的测试样例文件,蕴含了残缺的细节应用。
正文完
 0