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

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

>> 博客原文鏈接

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('app2', '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('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(){ ... };

  ...

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,基於 [email protected]

  3. 查看 test 目錄下的測試樣例文件,包含了完整的細節使用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章