基於Electron的smb客戶端文件上傳優化探索

>> 博客原文連接javascript

I 前言


smb_upload_now.jpg

上一篇文章《基於Electron的smb客戶端開發記錄》,大體描述了整個SMB客戶端開發的核心功能、實現難點、項目打包這些內容,這篇文章呢單獨把其中的文件分片上傳模塊拿出來進行分享,說起一些與Electron主進程、渲染進程和文件上傳優化相關的功能點。html

II Demo運行


項目精簡版 DEMO地址,刪除了smb處理的多餘邏輯,使用文件複製模擬上傳流程,可直接運行體驗。前端

demo運行時須要分別開啓兩個開發環境view -> service,而後才能預覽界面,因爲沒有後端,文件默認上傳(複製)到electron數據目錄(在Ubuntu上是 ~/.config/FileSliceUpload/runtime/upload)
# 進入view目錄
$: npm install
$: npm start
# 進入service目錄
$: npm install
$: npm start

III Electron進程架構


主進程和渲染進程的區別

electron1.png

Electron 運行 package.json 的 main 腳本的進程被稱爲主進程。在主進程中運行的腳本經過建立web頁面來展現用戶界面,一個 Electron 應用老是有且只有一個主進程。
主進程使用 BrowserWindow 實例建立頁面,每一個 BrowserWindow 實例都在本身的渲染進程裏運行頁面,當一個 BrowserWindow 實例被銷燬後,相應的渲染進程也會被終止。
主進程管理全部的web頁面和它們對應的渲染進程,每一個渲染進程都是獨立的,它只關心它所運行的 web 頁面。 java

在普通的瀏覽器中,web頁面一般在沙盒環境中運行,而且沒法訪問操做系統的原生資源。 然而 Electron 的用戶在 Node.js 的 API 支持下能夠在頁面中和操做系統進行一些底層交互。
在頁面中調用與 GUI 相關的原生 API 是不被容許的,由於在 web 頁面裏操做原生的 GUI 資源是很是危險的,並且容易形成資源泄露。 若是你想在 web 頁面裏使用 GUI 操做,其對應的渲染進程必須與主進程進行通信,請求主進程進行相關的 GUI 操做。node

主進程和渲染進程之間的通訊

1/2-自帶方法,3-外部擴展方法

1.使用remote遠程調用react

remote模塊爲渲染進程和主進程通訊提供了一種簡單方法,使用remote模塊, 你能夠調用main進程對象的方法, 而沒必要顯式發送進程間消息。示例以下,代碼經過remote遠程調用主進程的BrowserWindows建立了一個渲染進程,並加載了一個網頁地址:git

/* 渲染進程中(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調用不適用於主進程和渲染進程頻繁通訊以及耗時請求的狀況,不然會引發嚴重的程序性能問題。github

2.使用ipc信號通訊web

基於事件觸發的ipc雙向信號通訊,渲染進程中的ipcRenderer能夠監聽一個事件通道,也能向主進程或其它渲染進程直接發送消息(須要知道其它渲染進程的webContentsId),同理主進程中的ipcMain也能監聽某個事件通道和向任意一個渲染進程發送消息。算法

/* 主進程 */
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. 使用electron-re進行多向通訊

electron-re 是以前開發的一個處理electron進程間通訊的工具,已經發布爲npm組件。主要功能是在Electron已有的Main Process主進程 和 Renderer Process渲染進程概念的基礎上獨立出一個單獨的service邏輯。service即不須要顯示界面的後臺進程,它不參與UI交互,單獨爲主進程或其它渲染進程提供服務,它的底層實現爲一個容許node注入remote調用的渲染窗口進程。

好比在你看過一些Electron最佳實踐中,耗費cpu的操做是不建議被放到主進程中處理的,這時候咱們就能夠將這部分耗費cpu的操做編寫成一個單獨的js文件,而後使用service構造函數以這個js文件的地址path爲參數構造一個service實例,並經過electron-re提供的MessageChannel通訊工具在主進程、渲染進程、service進程之間任意發送消息,能夠參考如下示例代碼:

  • 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();
    // send data to a service - like the build-in ipcMain.send
    MessageChannel.send('app', 'channel1', { value: 'test1' });
    // send data to a service and return a Promise - extension method
    MessageChannel.invoke('app', 'channel2', { value: 'test1' }).then((response) => {
      console.log(response);
    });
    // listen a channel, same as ipcMain.on
    MessageChannel.on('channel3', (event, response) => {
      console.log(response);
    });

    // handle a channel signal, same as ipcMain.handle
    // you can return data directly or return a Promise instance
    MessageChannel.handle('channel4', (event, response) => {
      console.log(response);
      return { res: 'channel4-res' };
    });
});
  • 2)app.service.js
const { ipcRenderer } = require('electron');
const { MessageChannel } = require('electron-re');

// listen a channel, same as ipcRenderer.on
MessageChannel.on('channel1', (event, result) => {
  console.log(result);
});

// handle a channel signal, just like ipcMain.handle
MessageChannel.handle('channel2', (event, result) => {
  console.log(result);
  return { response: 'channel2-response' }
});

// send data to another service and return a promise , just like ipcRenderer.invoke
MessageChannel.invoke('app2', 'channel3', { value: 'channel3' }).then((event, result) => {
  console.log(result);
});

// send data to a service - like the build-in ipcRenderer.send
MessageChannel.send('app', 'channel4', { value: 'channel4' });
  • 3)app2.service.js
// handle a channel signal, just like ipcMain.handle
MessageChannel.handle('channel3', (event, result) => {
  console.log(result);
  return { response: 'channel3-response' }
});
// listen a channel, same as ipcRenderer.once
MessageChannel.once('channel4', (event, result) => {
  console.log(result);
});
// send data to main process, just like ipcRenderer.send
MessageChannel.send('main', 'channel3', { value: 'channel3' });
// send data to main process and return a Promise, just like ipcRenderer.invoke
MessageChannel.invoke('main', 'channel4', { value: 'channel4' });
  • 4)renderer process window
const { ipcRenderer } = require('electron');
const { MessageChannel } = require('electron-re');
// send data to a service
MessageChannel.send('app', ....);
MessageChannel.invoke('app2', ....);
// send data to main process
MessageChannel.send('main', ....);
MessageChannel.invoke('main', ....);

IV 文件上傳架構


文件上傳主要邏輯控制部分是前端的JS腳本代碼,位於主窗口所在的render渲染進程,負責用戶獲取系統目錄文件、生成上傳任務隊列、動態展現上傳任務列表詳情、任務列表的增刪查改等;主進程Electron端的Node.js代碼主要負責響應render進程的控制命令進行文件上傳任務隊列數據的增刪查改、上傳任務在內存和磁盤的同步、文件系統的交互、系統原生組件調用等。

文件上傳源和上傳目標

  • 在用戶界面上使用Input組件獲取到的FileList(HTML5 API,用於web端的簡單文件操做)即爲上傳源;
  • 上傳目標地址是遠端集羣某個節點的smb服務,由於Node.js NPM生態對smb的支持有限,目前並未發現一個能夠支持經過smb協議進行文件分片上傳的npm庫,因此考慮使用Node.js的FS API進行文件分段讀取而後將分片數據逐步增量寫入目標地址來模擬文件分片上傳過程,從而實如今界面上單個大文件上傳任務的啓動、暫停、終止和續傳等操做,因此這裏的解決方案是使用Windows UNC命令鏈接後端共享後,能夠像訪問本地文件系統同樣訪問遠程一個遠程smb共享路徑,好比文件路徑\\[host]\[sharename]\file1上的file1在執行了unc鏈接後就能夠經過Node.js FS API進行操做,跟操做本地文件徹底一致。整個必須依賴smb協議的上傳流程即精簡爲將本地拿到的文件數據複製到能夠在本地訪問的另外一個smb共享路徑這一流程,而這一切都得益於Windows UNC命令。
/* 使用unc命令鏈接遠程smb共享 */
_uncCommandConnect_Windows_NT({ host, username, pwd }) {
    const { isThirdUser, nickname, isLocalUser } = global.ipcMainProcess.userModel.info;
    const commandUse = `net use \\\\${host}\\ipc$ "${pwd}" /user:"${username}"`;
    return new Promise((resolve) => {
      this.sudo.exec(commandUse).then((res) => {
        resolve({
          code: 200,
        });
      }).catch((err) => {
        resolve({
          code: 600,
          result: global.lang.upload.unc_connection_failed
        });
      });
    });
  }

上傳流程概述

下圖描述了整個前端部分的控制邏輯:

shards_upload.jpg

  1. 頁面上使用<Input />組件拿到FileList對象(Electron環境下拿到的File對象會額外附加一個path屬性指明文件位於系統的絕對路徑)
  2. 緩存拿到的FileList,等待點擊上傳按鈕後開始讀取FileList列表並生成自定義的File文件對象數組用於存儲上傳任務列表信息
  3. 頁面調用init請求附帶上選中的文件信息初始化文件上傳任務
  4. Node.js拿到init請求附帶的文件信息後,將全部信息存入臨時存放在內存中的文件上傳列表中,並嘗試打開待上傳文件的文件描述符用於即將開始的文件切片分段上傳工做,最後返回給頁面上傳任務ID,Node.js端完成初始化處理
  5. 頁面拿到init請求成功的回調後,存儲返回的上傳任務ID,並將該文件加入文件待上傳隊列,在合適的時機開始上傳,開始上傳的時候向Node.js端發送upload請求,同時請求附帶上任務ID和當前的分片索引值(表示須要上傳第幾個文件分片)
  6. Node.js拿到upload請求後根據攜帶的任務ID讀取內存中的上傳任務信息,而後使用第二步打開的文件描述符和分片索引對本地磁盤中的目標文件進行分片切割,最後使用FS API將分片遞增寫入目標位置,即本地可直接訪問的SMB共享路徑
  7. upload請求成功後頁面判斷是否已經上傳完全部分片,若是完成則向Node.js發送complete請求,同時攜帶上任務ID
  8. Node.js根據任務ID獲取文件信息,關閉文件描述符,更新文件上傳任務爲上傳完成狀態
  9. 界面上傳任務列表所有完成後,向後端發送sync請求,把當前任務上傳列表同步到歷史任務(磁盤存儲)中,代表當前列表中全部任務已經完成
  10. Node.js拿到sync請求後,把內存中存儲的全部文件上傳列表信息寫入磁盤,同時釋放內存佔用,完成一次列表任務上傳

Node.js實現的文件分片管理工廠

  • 文件初始化的時候調用open方法臨時存儲文件描述符和文件絕對路徑的映射關係;
  • 文件上傳的時候調用read方法根據文件讀取位置、讀取容量大小進行分片切割;
  • 文件上傳完成的時候調用close關閉文件描述符;

三個方法均經過文件絕對路徑path參數創建關聯:

/**
  * readFileBlock [讀取文件塊]
  */
exports.readFileBlock = () => {

  const fdStore = {};
  const smallFileMap = {};

  return {
    /* 打開文件描述符 */
    open: (path, size, minSize=1024*2) => {
      return new Promise((resolve) => {
        try {
          // 小文件不打開文件描述符,直接讀取寫入
          if (size <= minSize) {
            smallFileMap[path] = true;
            return resolve({
              code: 200,
              result: {
                fd: null
              }
            });
          }
          // 打開文件描述符,建議絕對路徑和fd的映射關係
          fs.open(path, 'r', (err, fd) => {
            if (err) {
              console.trace(err);
              resolve({
                code: 601,
                result: err.toString()
              });
            } else {
              fdStore[path] = fd;
              resolve({
                code: 200,
                result: {
                  fd: fdStore[path]
                }
              });
            }
          });
        } catch (err) {
          console.trace(err);
          resolve({
            code: 600,
            result: err.toString()
          });
        }
      })
    },
  
    /* 讀取文件塊 */
    read: (path, position, length) => {
      return new Promise((resolve, reject) => {
        const callback = (err, data) => {
          if (err) {
            resolve({
              code: 600,
              result: err.toString()
            });
          } else {
            resolve({
              code: 200,
              result: data
            });
          }
        };
        try {
          // 小文件直接讀取,大文件使用文件描述符和偏移量讀取
          if (smallFileMap[path]) {
            fs.readFile(path, (err, buffer) => {
              callback(err, buffer);
            });
          } else {
            // 空文件處理
            if (length === 0) return callback(null, '');
            fs.read(fdStore[path], Buffer.alloc(length), 0, length, position, function(err, readByte, readResult){
              callback(err, readResult);
            });
          }
        } catch (err) {
          console.trace(err);
          resolve({
            code: 600,
            result: err.toString()
          });
        }
      });
    },

    /* 關閉文件描述符 */
    close: (path) => {
      return new Promise((resolve) => {
        try {
          if (smallFileMap[path]) {
            delete smallFileMap[path];
            resolve({
              code: 200
            });
          } else {
            fs.close(fdStore[path], () => {
              resolve({code: 200});
              delete fdStore[path];
            });
          }
        } catch (err) {
          console.trace(err);
          resolve({
            code: 600,
            result: err.toString()
          });
        }
      });
    },

    fdStore

  }

}

V 基於Electron的文件上傳卡頓優化踩坑


優化是一件頭大的事兒,由於你須要先經過不少測試手法找到現有代碼的性能瓶頸,而後編寫優化解決方案。我以爲找到性能瓶頸這一點就特別難,由於是本身寫的代碼因此容易陷入一些先入爲主的刻板思考模式。不過最最主要的一點仍是你若是本身都弄不清楚你使用的技術棧的話,那就無從談起優化,因此前面有很大篇幅分析了Electron進程方面的知識以及梳理了整個上傳流程。

使用Electron自帶的Devtools進行性能分析

在文件上傳過程當中打開性能檢測工具Performance進行錄製,分析整個流程:

upload_performance.jpg

在文件上傳過程當中打開內存工具Memory進行快照截取分析一個時刻的內存佔用狀況:

upload_memory.jpg

第一次嘗試解決問題:替換Antd Table組件

在編寫完成文件上傳模塊後,初步進行了壓力測試,結果發現添加1000個文件上傳任務到任務隊列,且同時上傳的文件上傳任務數量爲6時,上下滑動查看文件上傳列表時出現了卡頓的狀況,這種卡頓不侷限於某個界面組件的卡頓,並且當前窗口的全部操做都卡了起來,初步懷疑是Antd Table組件引發的卡頓,由於Antd Table組件是個很複雜的高階組件,在處理大量的數據時可能會有性能問題,遂我將Antd Table組件換成了原生的table組件,且Table列表只顯示每一個上傳任務的任務名,其他的諸如上傳進度這些都不予顯示,從而想避開這個問題。使人吃驚的是測試結果是即便換用了原生Table組件,卡頓狀況仍然毫無改善!

第二次嘗試解決問題:改造Electron主進程同步阻塞代碼

先看下chromium的架構圖,每一個渲染進程都有一個全局對象RenderProcess,用來管理與父瀏覽器進程的通訊,同時維護着一份全局狀態。瀏覽器進程爲每一個渲染進程維護一個RenderProcessHost對象,用來管理瀏覽器狀態和與渲染進程的通訊。瀏覽器進程和渲染進程使用Chromium的IPC系統進行通訊。在chromium中,頁面渲染時,UI進程須要和main process不斷的進行IPC同步,若此時main process忙,則UIprocess就會在IPC時阻塞。

chromium.jpg

綜上所述:若是主進程持續進行消耗CPU時間的任務或阻塞同步IO的任務的話,主進程就會在必定程度上阻塞,從而影響主進程和各個渲染進程之間的IPC通訊,IPC通訊有延遲或是受阻,天然渲染界面的UI繪製和更新就會呈現卡頓的狀態。

我分析了一下Node.js端的文件任務管理的代碼邏輯,把一些操做諸如獲取文件大小、獲取文件類型和刪除文件這類的同步阻塞IO調用都換成了Node.js提倡的異步調用模式,即FS callback或Fs Promise鏈式調用。改動後發現卡頓狀況改善不明顯,遂進行了第三次嘗試。

第三次嘗試解決問題:編寫Node.js進程池分離上傳任務管理邏輯

此次是大改😕

1. 簡單實現了node.js進程池
源碼:ChildProcessPool.class.js,主要邏輯是使用Node.js的child_process模塊(具體使用請看文檔) 建立指定數量的多個子進程,外部經過進程池獲取一個可用的進程,在進程中執行須要的代碼邏輯,而在進程池內部其實就是按照順序依次將已經建立的多個子進程中的某一個返回給外部調用便可,從而避免了其中某個進程被過分使用,省略代碼以下:

...
class ChildProcessPool {
  constructor({ path, max=6, cwd, env }) {
    this.cwd = cwd || process.cwd();
    this.env = env || process.env;
    this.inspectStartIndex = 5858;
    this.callbacks = {};
    this.pidMap = new Map();
    this.collaborationMap = new Map();
    this.forked = [];
    this.forkedPath = path;
    this.forkIndex = 0;
    this.forkMaxIndex = max;
  }
  /* Received data from a child process */
  dataRespond = (data, id) => { ... }

  /* Received data from all child processes */
  dataRespondAll = (data, id) => { ... }

  /* Get a process instance from the pool */
  getForkedFromPool(id="default") {
    let forked;

    if (!this.pidMap.get(id)) {
      // create new process
      if (this.forked.length < this.forkMaxIndex) {
        this.inspectStartIndex ++;
        forked = fork(
          this.forkedPath,
          this.env.NODE_ENV === "development" ? [`--inspect=${this.inspectStartIndex}`] : [],
          {
            cwd: this.cwd,
            env: { ...this.env, id },
          }
        );
        this.forked.push(forked);
        forked.on('message', (data) => {
          const id = data.id;
          delete data.id;
          delete data.action;
          this.onMessage({ data, id });
        });
      } else {
        this.forkIndex = this.forkIndex % this.forkMaxIndex;
        forked = this.forked[this.forkIndex];
      }

      if(id !== 'default')
        this.pidMap.set(id, forked.pid);
      if(this.pidMap.values.length === 1000)
        console.warn('ChildProcessPool: The count of pidMap is over than 1000, suggest to use unique id!');

      this.forkIndex += 1;
    } else {
      // use existing processes
      forked = this.forked.filter(f => f.pid === this.pidMap.get(id))[0];
      if (!forked) throw new Error(`Get forked process from pool failed! the process pid: ${this.pidMap.get(id)}.`);
    }

    return forked;
  }

  /**
    * onMessage [Received data from a process]
    * @param  {[Any]} data [response data]
    * @param  {[String]} id [process tmp id]
    */
  onMessage({ data, id }) {...}

  /* Send request to a process */
  send(taskName, params, givenId="default") {
    if (givenId === 'default') throw new Error('ChildProcessPool: Prohibit the use of this id value: [default] !')

    const id = getRandomString();
    const forked = this.getForkedFromPool(givenId);
    return new Promise(resolve => {
      this.callbacks[id] = resolve;
      forked.send({action: taskName, params, id });
    });
  }

  /* Send requests to all processes */
  sendToAll(taskName, params) {...}
}
  • 1)使用sendsendToAll方法向子進程發送消息,前者是向某個進程發送,若是請求參數指定了id則代表須要明確使用以前與此id創建過映射的某個進程,並指望拿到此進程的迴應結果;後者是向進程池中的全部進程發送信號,並指望拿到全部進程返回的結果(供調用者外部調用)。
  • 2)其中dataResponddataRespondAll方法對應上面的兩個信號發送方法的進程返回數據回調函數,前者拿到進程池中指定的某個進程的回調結果,後者拿到進程池中全部進程的回調結果(進程池內部方法,調用者無需關注)。
  • 3)getForkedFromPool方法是從進程池中拿到一個進程,若是進程池尚未一個子進程或是已經建立的子進程數量小於設置的可建立子進程數最大值,那麼會優先新建立一個子進程放入進程池,而後返回這個子進程以供調用(進程池內部方法,調用者無需關注)。
  • 4)getForkedFromPool方法中值得注意的是這行代碼:this.env.NODE_ENV === "development" ? [`--inspect=${this.inspectStartIndex}`] : [],使用Node.js運行js腳本時加上- -inspect=端口號 參數能夠開啓所運行進程的遠程調試端口,多進程程序狀態追蹤每每比較困難,因此採起這種方式後可使用瀏覽器Devtools單獨調試每一個進程(具體能夠在瀏覽器輸入地址:chrome://inspect/#devices而後打開調試配置項,配置咱們這邊指定的調試端口號,最後點擊藍字Open dedicated DevTools for Node就能打開一個調試窗口,能夠對代碼進程斷點調試、單步調試、步進步出、運行變量查看等操做,十分便利!)。

inspect.jpg

2. 分離子進程通訊邏輯和業務邏輯
另外被做爲子進程執行文件載入的js文件中可使用我封裝的ProcessHost.class.js,我把它稱爲進程事務管理中心,主要功能是使用api諸如 - ProcessHost.registry(taskName, func)來註冊多種任務,而後在主進程中能夠直接使用進程池獲取某個進程後向某個任務發送請求並取得Promise對象以拿到進程回調返回的數據,從而避免在咱們的子進程執行文件中編寫代碼時過分關注進程之間數據的通訊。
若是不使用進程事務管理中心的話咱們就須要使用process.send來向一個進程發送消息並在另外一個進程中使用process.on('message', processor)處理消息。須要注意的是若是註冊的task任務是異步的則須要返回一個Promise對象而不是直接return數據,簡略代碼以下:

  • 1)registry用於子進程向事務中心註冊本身的任務
  • 2)unregistry用於取消任務註冊
  • 3)handleMessage處理進程接收到的消息並根據action參數調用某個任務
class ProcessHost {
  constructor() {
    this.tasks = { };
    this.handleEvents();
    process.on('message', this.handleMessage.bind(this));
  }

  /* events listener */
  handleEvents() {...}

  /* received message */
  handleMessage({ action, params, id }) {
    if (this.tasks[action]) {
      this.tasks[action](params)
      .then(rsp => {
        process.send({ action, error: null, result: rsp || {}, id });
      })
      .catch(error => {
        process.send({ action, error, result: error || {}, id });
      });
    } else {
      process.send({
        action,
        error: new Error(`ProcessHost: processor for action-[${action}] is not found!`),
        result: null,
        id,
      });
    }
  }

  /* registry a task */
  registry(taskName, processor) {
    if (this.tasks[taskName]) console.warn(`ProcesHost: the task-${taskName} is registered!`);
    if (typeof processor !== 'function') throw new Error('ProcessHost: the processor must be a function!');
    this.tasks[taskName] = function(params) {
      return new Promise((resolve, reject) => {
        Promise.resolve(processor(params))
          .then(rsp => {
            resolve(rsp);
          })
          .catch(error => {
            reject(error);
          });
      })
    }

    return this;
  };

  /* unregistry a task */
  unregistry(taskName) {...};

  /* disconnect */
  disconnect() { process.disconnect(); }

  /* exit */
  exit() { process.exit(); }
}

global.processHost = global.processHost || new ProcessHost();
module.exports = global.processHost;

3. ChildProcessPool和ProcessHost的配合使用
具體使用請查看上文完整demo
1)main.js (in main process)
主進程中引入進程池類,並建立進程池實例

  • |——path參數爲可執行文件路徑
  • |——max指明進程池建立的最大子進程實例數量
  • |——env爲傳遞給子進程的環境變量
/* main.js */
...
const ChildProcessPool = require('path/to/ChildProcessPool.class');

global.ipcUploadProcess = new ChildProcessPool({
  path: path.join(app.getAppPath(), 'app/services/child/upload.js'),
  max: 3, // process instance
  env: { lang: global.lang, NODE_ENV: nodeEnv }
});
...

2)service.js (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 global.ipcUploadProcess.send(
            /* 進程事務名 */
            'init-works',
            /* 攜帶的參數 */
            {
              username, host, sharename, pre, prefix, size: file.size, name: file.name, abspath, fragsize, record: 
              {
                host, // 主機
                filename: path.join(prefix, file.name), // 文件名
                size, // 文件大小
                fragsize, // 分片大小
                abspath, // 絕對路徑
                startime: getTime(new Date().getTime()), // 上傳日期
                endtime: '', // 上傳日期
                uploadId, // 任務id
                index: 0,
                total: Math.ceil(size / fragsize),
                status: 'uploading' // 上傳狀態
              }
            },
            /* 指定一個進程調用id */
            uploadId
          )
        })
      .then((rsp) => {
        resolve({
          code: rsp.error ? 600 : 200,
          result: rsp.result,
        });
      }).catch(err => {
        resolve({
          code: 600,
          result: err.toString()
        });
      });
    });
  }

3)child.js (in child process) 使用事務管理中心處理消息
child.js即爲建立進程池時傳入的path參數所在的nodejs腳本代碼,在此腳本中咱們註冊多個任務來處理從進程池發送過來的消息。
這段代碼邏輯被單獨分離到子進程中處理,其中:

  • uploadStore - 主要用於在內存中維護整個文件上傳列表,對上傳任務列表進行增刪查改操做(cpu耗時操做)
  • fileBlock - 利用FS API操做文件,好比打開某個文件的文件描述符、根據描述符和分片索引值讀取一個文件的某一段Buffer數據、關閉文件描述符等等。雖然都是異步IO讀寫,對性能影響不大,不過爲了整合nodejs端上傳處理流程也將其一同歸入了子進程中管理,具體能夠查看源碼進行了解:源碼
const fs = require('fs');
  const fsPromise = fs.promises;
  const path = require('path');

  const utils = require('./child.utils');
  const { readFileBlock, uploadRecordStore, unlink } = utils;
  const ProcessHost = require('./libs/ProcessHost.class');

  // 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);
    })
    .registry('close', (params) => {
      return close(params);
    })
    .registry('record-set', (params) => {
      uploadStore.set(params);
      return { result: null };
    })
    .registry('record-get', (params) => {
      return uploadStore.get(params);
    })
    .registry('record-get-all', (params) => {
      return (uploadStore.getAll(params));
    })
    .registry('record-update', (params) => {
      uploadStore.update(params);
      return ({result: null});
    })
    .registry('record-remove', (params) => {
      uploadStore.remove(params);
      return { result: null };
    })
    .registry('record-reset', (params) => {
      uploadStore.reset(params);
      return { result: null };
    })
    .registry('unlink', (params) => {
      return unlink(params);
    });


  /* *************** upload logic *************** */

  /* 上傳初始化工做 */
  function initWorks({username, host, sharename, pre, prefix, name, abspath, size, fragsize, record }) {
    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 = {
            ...record,
            size, // 文件大小
            remotePath,
            username,
            host,
            sharename,
            startime: utils.getTime(new Date().getTime()), // 上傳日期
            total: Math.ceil(size / fragsize),
          };
          uploadStore.set(newRecord);
          return newRecord;
        } else {
          throw new Error(rsp.result);
        }
     })
     .then(resolve)
     .catch(error => {
      reject(error.toString());
     });
    })
  }

  ...

第四次嘗試解決問題:從新審視渲染進程前端代碼

  • 很遺憾,第三次優化對卡頓的改善依然不明顯,我開始懷疑是不是前端代碼直接影響的渲染進程卡頓,畢竟前端並不是採用懶加載模式進行文件載入上傳的(這一懷疑以前被我否認,由於前端代碼徹底沿用了以前瀏覽器端對象存儲文件分片上傳開發時的邏輯,而在對象存儲文件上傳中並未察覺到界面卡頓,屬實奇怪)。摒棄了先入爲主的思想,其實Electron跟瀏覽器環境仍是有些不一樣,不能排除前端代碼就沒有問題。
  • 在詳細查看了可能耗費CPU計算的代碼邏輯後,發現有一段關於刷新上傳任務的函數refreshTasks,主要邏輯是遍歷全部未經上傳文件原始對象數組,而後選取固定某個數量的文件(數量取決於設置的同時上傳任務個數)放入待上傳文件列表中,我發現若是待上傳文件列表的文件數量 = 設置的同時上傳任務個數 的狀況下就不用繼續遍歷剩下的文件原始對象數組了。就是少寫了這個判斷條件致使refreshTasks這個頻繁操做的函數在每次執行時可能多執行數千遍for循環內層判斷邏輯(具體執行次數呈O(n)次增加,n爲當前任務列表任務數量)。
  • 加上一行檢測邏輯代碼後,以前1000個上傳任務增加到10000個左右都不會太卡了,雖然還有略微卡頓,但沒有到不能使用的程度,後續還有優化空間!

refreshTasks.jpg

總結


第一次把Electron技術應用到實際項目中,踩了挺多坑:render進程和主進程通訊的問題、跨平臺兼容的問題、多平臺打包的問題、窗口管理的問題... 總之得到了不少經驗,也整理出了一些通用解決方法。 Electron如今應用的項目仍是挺多的,是前端同窗跨足桌面軟件開發領域的又一里程碑,不過須要轉換一下思惟模式,單純寫前端代碼可能是處理一些簡單的界面邏輯和少許的數據,涉及到文件、系統操做、進程線程、原生交互方面的知識比較少,能夠多瞭解一下計算機操做系統方面的知識、掌握代碼設計模式和一些基本的算法優化方面的知識能讓你更加勝任Electron桌面軟件開發任務!

相關文章
相關標籤/搜索