文中实现的部分工具方法正处于早期/测试阶段,仍在持续优化中,仅供参考...
>>博客原文链接javascript
├── Contents (you are here!) │ ├── I. 前言 ├── II. 架构图 │ ├── III. electron-re 能够用来作什么? │ ├── 1) 用于Electron应用 │ └── 2) 用于Electron/Nodejs应用 │ ├── IV. 说明1:Service/MessageChannel │ ├── Service的建立 │ ├── Service的自动刷新 │ ├── MessageChannel的引入 │ ├── MessageChannel提供的方法 │ └── 对比MessageChannel和原生ipc通讯的使用 │ ├── 1) 使用remote远程调用(原生) │ ├── 2) 使用ipc信号通讯(原生) │ └── 3) 使用MessageChannel进行多向通讯(扩展) │ ├── V. 说明2:ChildProcessPool/ProcessHost │ ├── 进程池的建立 │ ├── 进程池的实例方法 │ ├── 子进程事务中心 │ └── 进程池和子进程事务中心的配合使用 │ ├── 1) 主进程中使用进程池向子进程发送请求 │ └── 2) 子进程中用事务中心处理消息 │ ├── VI. Next To Do │ ├── VII. 几个实际使用示例 │ ├── 1) Service/MessageChannel示例 │ ├── 2) ChildProcessPool/ProcessHost示例 │ └── 3) test测试目录示例
最近在作一个多文件分片并行上传模块的时候(基于Electron和React),遇到了一些性能问题,主要体如今:前端同时添加大量文件(1000-10000)并行上传时(文件同时上传数默认为6),在不作懒加载优化的状况下,引发了整个应用窗口的卡顿。因此针对Electron/Nodejs多进程这方面作了一些学习,尝试使用多进程架构对上传流程进行优化。前端
同时也编写了一个方便进行Electron/Node多进程管理和调用的工具electron-re,已经发布为npm组件,能够直接安装:java
>> github地址node
$: npm install electron-re --save # or $: yarn add electron-re
若是感兴趣是怎么一步一步解决性能问题的话能够查看这篇文章:《基于Electron的smb客户端文件上传优化探索》。git
下面来说讲主角=> electron-regithub
BrowserService
MessageChannel
在Electron的一些“最佳实践”中,建议将占用cpu的代码放到渲染过程当中而不是直接放在主过程当中,这里先看下chromium的架构图:web
每一个渲染进程都有一个全局对象RenderProcess,用来管理与父浏览器进程的通讯,同时维护着一份全局状态。浏览器进程为每一个渲染进程维护一个RenderProcessHost对象,用来管理浏览器状态和与渲染进程的通讯。浏览器进程和渲染进程使用Chromium的IPC系统进行通讯。在chromium中,页面渲染时,UI进程须要和main process不断的进行IPC同步,若此时main process忙,则UIprocess就会在IPC时阻塞。因此若是主进程持续进行消耗CPU时间的任务或阻塞同步IO的任务的话,就会在必定程度上阻塞,从而影响主进程和各个渲染进程之间的IPC通讯,IPC通讯有延迟或是受阻,渲染进程窗口就会卡顿掉帧,严重的话甚至会卡住不动。npm
所以electron-re
在Electron已有的Main Process
主进程和Renderer Process
渲染进程逻辑的基础上独立出一个单独的Service
概念。Service
即不须要显示界面的后台进程,它不参与UI交互,单独为主进程或其它渲染进程提供服务,它的底层实现为一个容许node注入
和remote调用
的渲染窗口进程。api
这样就能够将代码中耗费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' });
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); });
用于Electron应用中 - Service进程分离/进程间通讯
须要等待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(); ... });
支持Service代码文件更新后自动刷新Service,简单设置两个配置项便可。
1.须要声明当前运行环境为开发环境
2.建立Service时禁用web安全策略
const myService = new BrowserService('app', 'path/to/app.service.js', { ...options, // 设置开发模式 dev: true, // 关闭安全策略 webPreferences: { webSecurity: false } });
注意必须在main.js中引入,引入后会自动进行初始化。
MessageChannel在主进程/Service/渲染进程窗口
中的使用方式基本一致,具体请参考下文"对比MessageChannel和原生ipc通讯的使用"。
const { BrowserService, MessageChannel // must required in main.js even if you don't use it } = require('electron-re');
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);
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进行多向通讯
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' }; }); });
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' });
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' });
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' });
用于Electron和Nodejs应用中 - Node.js进程池/子进程事务中心
进程池基于nodejs的child_process
模块,使用fork
方式建立并管理多个独立的子进程。
建立进程池时提供最大子进程实例个数
、子进程执行文件路径
等参数便可,进程池会自动接管进程的建立和调用。外部能够经过进程池向某个子进程发送请求,而在进程池内部其实就是按照顺序依次将已经建立的多个子进程中的某一个返回给外部调用便可,从而避免了其中某个进程被过分使用。
子进程是经过懒加载方式建立的,也就是说若是只建立进程池而不对进程池发起请求调用的话,进程池将不会建立任何子进程实例。
1.参数说明
|—— path 参数为可执行文件路径 |—— max 指明进程池建立的最大子进程实例数量 |—— env 为传递给子进程的环境变量
2.主进程中引入进程池类,并建立进程池实例
/* main.js */ ... const { ChildProcessPool } = require('electron-re'); 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
数据,实例方法以下:
使用说明:
/* 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(){ ... }; ...
BrowserService
and MessageChannel
。ChildProcessPool
and ProcessHost
,基于 Electron@9.3.5。test
目录下的测试样例文件,包含了完整的细节使用。