>>博客原文javascript
文中實現的部分工具方法正處於早期/測試階段,仍在持續優化中,僅供參考...在Ubuntu20.04上進行開發/測試,可直接用於Electron項目,測試版本:Electron@8.2.0 / 9.3.5前端
├── Contents (you are here!) │ ├── I. 前言 ├── II. 架構圖 │ ├── III.electron-re 能夠用來作什麼? │ ├── 1) 用於Electron應用 │ └── 2) 用於Electron/Nodejs應用 │ ├── IV.UI功能介紹 │ ├── 主界面 │ ├── 功能1:Kill進程 │ ├── 功能2:一鍵開啓DevTools │ ├── 功能3:查看進程日誌 │ └── 功能4:查看進程CPU/Memory佔用趨勢 │ ├── V. 使用&原理 │ ├── 引入 │ ├── 怎樣捕獲進程資源佔用? │ ├── 怎樣在主進程和UI之間共享數據? │ └── 怎樣在UI窗口中繪製折線圖? │ ├── VI. 存在的已知問題 │ ├── VII. Next To Do │ ├── VIII. 幾個實際使用示例 │ ├── 1) Service/MessageChannel示例 │ ├── 2) ChildProcessPool/ProcessHost示例 │ └── 3) test測試目錄示例
最近在作一個多文件分片並行上傳模塊的時候(基於Electron和React),遇到了一些性能問題,主要體如今:前端同時添加大量文件(1000-10000)並行上傳時(文件同時上傳數默認爲6),在不作懶加載優化的狀況下,引發了整個應用窗口的卡頓。因此針對Electron/Nodejs多進程這方面作了一些學習,嘗試使用多進程架構對上傳流程進行優化。java
同時也編寫了一個方便進行Electron/Node多進程管理和調用的工具electron-re,已經發布爲npm組件,能夠直接安裝:node
>> github地址git
$: npm install electron-re --save # or $: yarn add electron-re
前文《Electron/Node多進程工具開發日記》描述了electron-re
的開發背景、針對的問題場景以及詳細的使用方法,這篇文章不會對它的基礎使用作過多說明,主要介紹新特性多進程管理UI
的開發相關。UI界面基於electron-re
已有的BrowserService/MessageChannel
和ChildProcessPool/ProcessHost
基礎架構驅動,使用React17 / Babel7開發,主界面:github
BrowserService
MessageChannel
在Electron的一些「最佳實踐」中,建議將佔用cpu的代碼放到渲染過程當中而不是直接放在主過程當中,這裏先看下chromium的架構圖:web
每一個渲染進程都有一個全局對象RenderProcess,用來管理與父瀏覽器進程的通訊,同時維護着一份全局狀態。瀏覽器進程爲每一個渲染進程維護一個RenderProcessHost對象,用來管理瀏覽器狀態和與渲染進程的通訊。瀏覽器進程和渲染進程使用Chromium的IPC系統進行通訊。在chromium中,頁面渲染時,UI進程須要和main process不斷的進行IPC同步,若此時main process忙,則UIprocess就會在IPC時阻塞。因此若是主進程持續進行消耗CPU時間的任務或阻塞同步IO的任務的話,就會在必定程度上阻塞,從而影響主進程和各個渲染進程之間的IPC通訊,IPC通訊有延遲或是受阻,渲染進程窗口就會卡頓掉幀,嚴重的話甚至會卡住不動。chrome
所以electron-re
在Electron已有的Main Process
主進程和Renderer Process
渲染進程邏輯的基礎上獨立出一個單獨的Service
概念。Service
即不須要顯示界面的後臺進程,它不參與UI交互,單獨爲主進程或其它渲染進程提供服務,它的底層實現爲一個容許node注入
和remote調用
的渲染窗口進程。shell
這樣就能夠將代碼中耗費cpu的操做(好比文件上傳中維護一個數千個上傳任務的隊列)編寫成一個單獨的js文件,而後使用BrowserService
構造函數以這個js文件的地址path
爲參數構造一個Service
實例,從而將他們從主進程中分離。若是你說那這部分耗費cpu的操做直接放到渲染窗口進程能夠嘛?這其實取決於項目自身的架構設計,以及對進程之間數據傳輸性能損耗和傳輸時間等各方面的權衡,建立一個Service
的簡單示例:npm
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)
來實現(前提是進程以以fork
方式建立或者手動開啓了ipc
通訊)。可是這樣在處理業務邏輯的同時也強迫咱們去關注進程之間的通訊,你須要知道子進程何時能處理完畢,而後再使用process.send
再將數據返回主進程,使用方式繁瑣。
electron-re
引入了ProcessHost
的概念,我稱之爲"進程事務中心"。實際使用時在子進程執行文件中只須要將各個任務函數經過ProcessHost.registry('task-name', function)
註冊成多個被監聽的事務,而後配合進程池的ChildProcessPool.send('task-name', params)
來觸發子進程事務邏輯的調用便可,ChildProcessPool.send()
同時會返回一個Promise實例以便獲取回調數據,簡單示例以下:
/* --- 主進程中 --- */ ... global.ipcUploadProcess .send('task1', params) .then(rsp => console.log(rsp)); /* --- 子進程中 --- */ const { ProcessHost } = require('electron-re'); ProcessHost .registry('task1', (params) => { return { value: 'task-value' }; }) .registry('init-works', (params) => { return fetch(url); });
II 描述了electron-re的主要功能,基於這些功能來實現多進程監控UI面板
UI參考
electron-process-manager
設計
預覽圖:
主要功能以下:
DevTools
按鈕一鍵打開內置調試工具。--inspect
參數,可使用chrome的chrome://inspect
進行遠程調試。
1. 在Electron主進程入口文件中引入:
const { MessageChannel, // must required in main.js even if you don't use it ProcessManager } = require('electron-re');
2. 開啓進程管理窗口UI
ProcessManager.openWindow();
1.使用ProcessManager監聽多個進程號
/* --- src/index.js --- */ ... app.on('web-contents-created', (event, webContents) => { webContents.once('did-finish-load', () => { const pid = webContents.getOSProcessId(); if ( exports.ProcessManager.processWindow && exports.ProcessManager.processWindow.webContents.getOSProcessId() === pid ) { return; } exports.ProcessManager.listen(pid, 'renderer'); webContents.once('closed', function(e) { exports.ProcessManager.unlisten(this.pid); }.bind({ pid })); ... }) });
/* --- src/libs/ChildProcessPool.class.js --- */ ... const { fork } = require('child_process'); class ChildProcessPool { constructor({ path, max=6, cwd, env }) { ... this.event = new EventEmitter(); this.event.on('fork', (pids) => { ProcessManager.listen(pids, 'node'); }); this.event.on('unfork', (pids) => { ProcessManager.unlisten(pids); }); } /* Get a process instance from the pool */ getForkedFromPool(id="default") { let forked; ... forked = fork(this.forkedPath, ...); this.event.emit('fork', this.forked.map(fork => fork.pid)); ... return forked; } ... }
BrowserService
進程建立時會向主進程MessageChannel
發送registry
請求來全局註冊一個Service服務,此時將進程id放入監聽列表便可:
/* --- src/index.js --- */ ... exports.MessageChannel.event.on('registry', ({pid}) => { exports.ProcessManager.listen(pid, 'service'); }); ... exports.MessageChannel.event.on('unregistry', ({pid}) => { exports.ProcessManager.unlisten(pid) });
2.使用兼容多平臺的pidusage
庫每秒採集一次進程的負載數據:
/* --- src/libs/ProcessManager.class.js --- */ ... const pidusage = require('pidusage'); class ProcessManager { constructor() { this.pidList = [process.pid]; this.typeMap = { [process.pid]: 'main', }; ... } /* -------------- internal -------------- */ /* 設置外部庫採集併發送到UI進程 */ refreshList = () => { return new Promise((resolve, reject) => { if (this.pidList.length) { pidusage(this.pidList, (err, records) => { if (err) { console.log(`ProcessManager: refreshList -> ${err}`); } else { this.processWindow.webContents.send('process:update-list', { records, types: this.typeMap }); } resolve(); }); } else { resolve([]); } }); } /* 設置定時器進行採集 */ setTimer() { if (this.status === 'started') return console.warn('ProcessManager: the timer is already started!'); const interval = async () => { setTimeout(async () => { await this.refreshList() interval(this.time) }, this.time) } this.status = 'started'; interval() } ...
3.監聽進程輸出來採集進程日誌
進程池建立的子進程能夠經過監聽stdout
標準輸出流來進行日誌採集;Electron渲染窗口進程則能夠經過監聽ipc
通訊事件console-message
來進行採集;
/* --- src/libs/ProcessManager.class.js --- */ class ProcessManager { constructor() { ... } /* pipe to process.stdout */ pipe(pinstance) { if (pinstance.stdout) { pinstance.stdout.on( 'data', (trunk) => { this.stdout(pinstance.pid, trunk); } ); } } ... } /* --- src/index.js --- */ app.on('web-contents-created', (event, webContents) => { webContents.once('did-finish-load', () => { const pid = webContents.getOSProcessId(); ... webContents.on('console-message', (e, level, msg, line, sourceid) => { exports.ProcessManager.stdout(pid, msg); }); ... }) });
基於Electron原生
ipc
異步通訊
1.使用ProcessManager向UI渲染窗口發送日誌數據
每秒採集到的全部進程的console數據會被臨時緩存到數組中,默認1秒鐘向UI進程發送一次數據,而後清空臨時數組。
在這裏須要注意的是ChildProcessPool中的子進程是經過Node.js的child_process.fork()
方法建立的,此方法會衍生shell,且建立子進程時參數stdio
會被指定爲'pipe',指明在子進程和父進程之間建立一個管道,從而讓父進程中能夠直接監聽子進程對象上的 stdout.on('data')
事件來拿到子進程的標準輸出流。
/* --- src/libs/ProcessManager.class.js --- */ class ProcessManager { constructor() { ... } /* pipe to process.stdout */ pipe(pinstance) { if (pinstance.stdout) { pinstance.stdout.on( 'data', (trunk) => { this.stdout(pinstance.pid, trunk); } ); } } /* send stdout to ui-processor */ stdout(pid, data) { if (this.processWindow) { if (!this.callSymbol) { this.callSymbol = true; setTimeout(() => { this.processWindow.webContents.send('process:stdout', this.logs); this.logs = []; this.callSymbol = false; }, this.time); } else { this.logs.push({ pid: pid, data: String.prototype.trim.call(data) }); } } } ... }
2.使用ProcessManager向UI渲染窗口發送進程負載信息
/* --- src/libs/ProcessManager.class.js --- */ class ProcessManager { constructor() { ... } /* 設置外部庫採集併發送到UI進程 */ refreshList = () => { return new Promise((resolve, reject) => { if (this.pidList.length) { pidusage(this.pidList, (err, records) => { if (err) { console.log(`ProcessManager: refreshList -> ${err}`); } else { this.processWindow.webContents.send('process:update-list', { records, types: this.typeMap }); } resolve(); }); } else { resolve([]); } }); } ... }
3.UI窗口拿到數據後處理並臨時存儲
import { ipcRenderer, remote } from 'electron'; ... ipcRenderer.on('process:update-list', (event, { records, types }) => { console.log('update:list'); const { history } = this.state; for (let pid in records) { history[pid] = history[pid] || { memory: [], cpu: [] }; if (!records[pid]) continue; history[pid].memory.push(records[pid].memory); history[pid].cpu.push(records[pid].cpu); // 存儲最近的60條進程負載數據 history[pid].memory = history[pid].memory.slice(-60); history[pid].cpu = history[pid].cpu.slice(-60); } this.setState({ processes: records, history, types }); }); ipcRenderer.on('process:stdout', (event, dataArray) => { console.log('process:stdout'); const { logs } = this.state; dataArray.forEach(({ pid, data })=> { logs[pid] = logs[pid] || []; logs[pid].unshift(`[${new Date().toLocaleTimeString()}]: ${data}`); }); // 存儲最近的1000個日誌輸出 Object.keys(logs).forEach(pid => { logs[pid].slice(0, 1000); }); this.setState({ logs }); });
1.注意使用React.PureComponent,會自動在屬性更新進行淺比較,以減小沒必要要的渲染
/* *************** ProcessTrends *************** */ export class ProcessTrends extends React.PureComponent { componentDidMount() { ... } ... render() { const { visible, memory, cpu } = this.props; if (visible) { this.uiDrawer.draw(); this.dataDrawer.draw(cpu, memory); }; return ( <div className={`process-trends-container ${!visible ? 'hidden' : 'progressive-show' }`}> <header> <span className="text-button small" onClick={this.handleCloseTrends}>X</span> </header> <div className="trends-drawer"> <canvas width={document.body.clientWidth * window.devicePixelRatio} height={document.body.clientHeight * window.devicePixelRatio} id="trendsUI" /> <canvas width={document.body.clientWidth * window.devicePixelRatio} height={document.body.clientHeight * window.devicePixelRatio} id="trendsData" /> </div> </div> ) } }
2.使用兩個Canvas畫布分別繪製座標軸和折線線段
設置兩個畫布相互重疊以儘量保證靜態的座標軸不會被重複繪製,咱們須要在組件掛載後初始化一個座標軸繪製對象uiDrawer
和一個數據折線繪製對象dataDrawer
... componentDidMount() { this.uiDrawer = new UI_Drawer('#trendsUI', { xPoints: 60, yPoints: 100 }); this.dataDrawer = new Data_Drawer('#trendsData'); window.addEventListener('resize', this.resizeDebouncer); } ...
如下是Canvas相關的基礎繪製命令:
this.canvas = document.querySelector(selector); this.ctx = this.canvas.getContext('2d'); this.ctx.strokeStyle = lineColor; // 設置線段顏色 this.ctx.beginPath(); // 建立一個新的路徑 this.ctx.moveTo(x, y); // 移動到初始座標點(不進行繪製) this.ctx.lineTo(Math.floor(x), Math.floor(y)); // 描述從上一個座標點到(x, y)的一條直線 this.ctx.stroke(); // 開始繪製
繪製類的源代碼能夠查看這裏Drawer,大概原理是:設置Canvas畫布寬度width和高度height鋪滿窗口,設定橫縱座標軸到邊緣的padding值爲30,Canvas座標原點[0,0]爲繪製區域左上角頂點。這裏以繪製折線圖縱軸座標爲例,縱軸表示CPU佔用0%-100%或內存佔用0-1GB,咱們能夠將縱軸劃分爲100個基礎單位,可是縱軸座標點不用爲100個,能夠設置爲10個方便查看,因此每一個座標點就能夠表示爲[0, (height-padding) - ((height-(2*padding)) / index) * 100 ]
,index依次等於0,10,20,30...90,其中(height-padding)
爲最下面那個座標點位置,(height-(2*padding))
爲整個縱軸的長度。
1.生產環境下ChildProcessPool未按預期工做
Electron生產環境下,若是app被安裝到系統目錄,那麼ChildProcessPool不能按照預期工做,解決辦法有:將app安裝到用戶目錄或者把進程池用於建立子進程的腳本(經過path
參數指定)單獨放到Electron用戶數據目錄下(Ubuntu20.04上是~/.config/[appname]
)。
2.UI界面未監聽主進程Console數據
主進程暫未支持此功能,正在尋找解決方案。
BrowserService/MessageChannel
,而且附帶了ChildProcessPool/ProcessHost
使用demo。ChildProcessPool
and ProcessHost
,基於 Electron@9.3.5開發。test
目錄下的測試樣例文件,包含了完整的細節使用。