Electron/Node多進程工具開發日記

文中實現的部分工具方法正處於早期/測試階段,仍在持續優化中,僅供參考...

>>博客原文鏈接javascript

Contents


├── 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測試目錄示例

I. 前言


最近在作一個多文件分片並行上傳模塊的時候(基於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

smb_upload_now.jpg

下面來說講主角=> electron-regithub

II. electron-re架構圖


electron-re.png

III. electron-re 能夠用來作什麼?


1. 用於Electron應用

  • BrowserService
  • MessageChannel

在Electron的一些「最佳實踐」中,建議將佔用cpu的代碼放到渲染過程當中而不是直接放在主過程當中,這裏先看下chromium的架構圖:web

chromium.jpg

每一個渲染進程都有一個全局對象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' });

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

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

V. ChildProcessPool/ProcessHost


用於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數據,實例方法以下:

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

  ...

VI. Next To Do


  • [✓] 讓Service支持代碼更新後自動重啓
  • [x] 添加ChildProcessPool子進程調度邏輯
  • [x] 優化ChildProcessPool多進程console輸出
  • [x] 添加可視化進程管理界面
  • [x] 加強ChildProcessPool進程池功能
  • [x] 加強ProcessHost事務中心功能

VII. 幾個實際使用示例


  1. electronux - 個人一個Electron項目,使用了 BrowserService and MessageChannel
  2. file-slice-upload - 一個關於多文件分片並行上傳的demo,使用了 ChildProcessPool and ProcessHost,基於 Electron@9.3.5。
  3. 查看 test 目錄下的測試樣例文件,包含了完整的細節使用。
相關文章
相關標籤/搜索