基于Electron的smb客戶端文件上傳優(yōu)化探索

文中實(shí)現(xiàn)的部分工具方法正處于早期/測(cè)試階段就斤,仍在持續(xù)優(yōu)化中户侥,僅供參考...

I 前言


smb_upload_now.jpg

上一篇文章《基于Electron的smb客戶端開發(fā)記錄》,大致描述了整個(gè)SMB客戶端開發(fā)的核心功能院塞、實(shí)現(xiàn)難點(diǎn)钟鸵、項(xiàng)目打包這些內(nèi)容,這篇文章呢單獨(dú)把其中的文件分片上傳模塊拿出來(lái)進(jìn)行分享撇眯,提及一些與Electron主進(jìn)程报嵌、渲染進(jìn)程和文件上傳優(yōu)化相關(guān)的功能點(diǎn)。

II Demo運(yùn)行


項(xiàng)目精簡(jiǎn)版 DEMO地址熊榛,刪除了smb處理的多余邏輯锚国,使用文件復(fù)制模擬上傳流程,可直接運(yùn)行體驗(yàn)来候。

demo運(yùn)行時(shí)需要分別開啟兩個(gè)開發(fā)環(huán)境view -> service跷叉,然后才能預(yù)覽界面,由于沒(méi)有后端营搅,文件默認(rèn)上傳(復(fù)制)到electron數(shù)據(jù)目錄(在Ubuntu上是~/.config/FileSliceUpload/runtime/upload)

# 進(jìn)入view目錄
$: npm install
$: npm start
# 進(jìn)入service目錄
$: npm install
$: npm start

III Electron進(jìn)程架構(gòu)


主進(jìn)程和渲染進(jìn)程的區(qū)別

electron1.png

Electron 運(yùn)行 package.json 的 main 腳本的進(jìn)程被稱為主進(jìn)程云挟。在主進(jìn)程中運(yùn)行的腳本通過(guò)創(chuàng)建web頁(yè)面來(lái)展示用戶界面,一個(gè) Electron 應(yīng)用總是有且只有一個(gè)主進(jìn)程转质。
主進(jìn)程使用 BrowserWindow 實(shí)例創(chuàng)建頁(yè)面园欣,每個(gè) BrowserWindow 實(shí)例都在自己的渲染進(jìn)程里運(yùn)行頁(yè)面,當(dāng)一個(gè) BrowserWindow 實(shí)例被銷毀后休蟹,相應(yīng)的渲染進(jìn)程也會(huì)被終止沸枯。
主進(jìn)程管理所有的web頁(yè)面和它們對(duì)應(yīng)的渲染進(jìn)程日矫,每個(gè)渲染進(jìn)程都是獨(dú)立的,它只關(guān)心它所運(yùn)行的 web 頁(yè)面绑榴。

在普通的瀏覽器中哪轿,web頁(yè)面通常在沙盒環(huán)境中運(yùn)行,并且無(wú)法訪問(wèn)操作系統(tǒng)的原生資源翔怎。 然而 Electron 的用戶在 Node.js 的 API 支持下可以在頁(yè)面中和操作系統(tǒng)進(jìn)行一些底層交互窃诉。
在頁(yè)面中調(diào)用與 GUI 相關(guān)的原生 API 是不被允許的,因?yàn)樵?web 頁(yè)面里操作原生的 GUI 資源是非常危險(xiǎn)的赤套,而且容易造成資源泄露飘痛。 如果你想在 web 頁(yè)面里使用 GUI 操作,其對(duì)應(yīng)的渲染進(jìn)程必須與主進(jìn)程進(jìn)行通訊容握,請(qǐng)求主進(jìn)程進(jìn)行相關(guān)的 GUI 操作宣脉。

主進(jìn)程和渲染進(jìn)程之間的通信

1/2-自帶方法,3-外部擴(kuò)展方法

1. 使用remote遠(yuǎn)程調(diào)用

remote模塊為渲染進(jìn)程和主進(jìn)程通信提供了一種簡(jiǎn)單方法剔氏,使用remote模塊, 你可以調(diào)用main進(jìn)程對(duì)象的方法, 而不必顯式發(fā)送進(jìn)程間消息塑猖。示例如下,代碼通過(guò)remote遠(yuǎn)程調(diào)用主進(jìn)程的BrowserWindows創(chuàng)建了一個(gè)渲染進(jìn)程介蛉,并加載了一個(gè)網(wǎng)頁(yè)地址:

/* 渲染進(jìn)程中(web端代碼) */
const { BrowserWindow } = require('electron').remote
let win = new BrowserWindow({ width: 800, height: 600 })
win.loadURL('https://github.com')

注意:remote底層是基于ipc的同步進(jìn)程通信(同步=阻塞頁(yè)面)萌庆,都知道Node.js的最大特性就是異步調(diào)用溶褪,非阻塞IO币旧,因此remote調(diào)用不適用于主進(jìn)程和渲染進(jìn)程頻繁通信以及耗時(shí)請(qǐng)求的情況,否則會(huì)引起嚴(yán)重的程序性能問(wèn)題猿妈。

2. 使用ipc信號(hào)通信

基于事件觸發(fā)的ipc雙向信號(hào)通信吹菱,渲染進(jìn)程中的ipcRenderer可以監(jiān)聽一個(gè)事件通道,也能向主進(jìn)程或其它渲染進(jìn)程直接發(fā)送消息(需要知道其它渲染進(jìn)程的webContentsId)彭则,同理主進(jìn)程中的ipcMain也能監(jiān)聽某個(gè)事件通道和向任意一個(gè)渲染進(jìn)程發(fā)送消息鳍刷。

/* 主進(jìn)程 */
ipcMain.on(channel, listener) // 監(jiān)聽信道 - 異步觸發(fā)
ipcMain.once(channel, listener) // 監(jiān)聽一次信道,監(jiān)聽器觸發(fā)后即刪除 - 異步觸發(fā)
ipcMain.handle(channel, listener) // 為渲染進(jìn)程的invoke函數(shù)設(shè)置對(duì)應(yīng)信道的監(jiān)聽器
ipcMain.handleOnce(channel, listener) // 為渲染進(jìn)程的invoke函數(shù)設(shè)置對(duì)應(yīng)信道的監(jiān)聽器俯抖,觸發(fā)后即刪除監(jiān)聽
browserWindow.webContents.send(channel, args); // 顯式地向某個(gè)渲染進(jìn)程發(fā)送信息 - 異步觸發(fā)


/* 渲染進(jìn)程 */
ipcRenderer.on(channel, listener); // 監(jiān)聽信道 - 異步觸發(fā)
ipcRenderer.once(channel, listener); // 監(jiān)聽一次信道输瓜,監(jiān)聽器觸發(fā)后即刪除 - 異步觸發(fā)
ipcRenderer.sendSync(channel, args); // 向主進(jìn)程一個(gè)信道發(fā)送信息 - 同步觸發(fā)
ipcRenderer.invoke(channel, args); // 向主進(jìn)程一個(gè)信道發(fā)送信息 - 返回Promise對(duì)象等待觸發(fā)
ipcRenderer.sendTo(webContentsId, channel, ...args); // 向某個(gè)渲染進(jìn)程發(fā)送消息 - 異步觸發(fā)
ipcRenderer.sendToHost(channel, ...args) // 向host頁(yè)面的webview發(fā)送消息 - 異步觸發(fā)

3. 使用electron-re進(jìn)行多向通信

electron-re 是之前開發(fā)的一個(gè)處理electron進(jìn)程間通信的工具,已經(jīng)發(fā)布為npm組件芬萍。主要功能是在Electron已有的Main Process主進(jìn)程 和 Renderer Process渲染進(jìn)程概念的基礎(chǔ)上獨(dú)立出一個(gè)單獨(dú)的==service==邏輯尤揣。service即不需要顯示界面的后臺(tái)進(jìn)程,它不參與UI交互柬祠,單獨(dú)為主進(jìn)程或其它渲染進(jìn)程提供服務(wù)北戏,它的底層實(shí)現(xiàn)為一個(gè)允許node注入remote調(diào)用的渲染窗口進(jìn)程。

比如在你看過(guò)一些Electron最佳實(shí)踐中漫蛔,耗費(fèi)cpu的操作是不建議被放到主進(jìn)程中處理的嗜愈,這時(shí)候我們就可以將這部分耗費(fèi)cpu的操作編寫成一個(gè)單獨(dú)的js文件旧蛾,然后使用service構(gòu)造函數(shù)以這個(gè)js文件的地址path為參數(shù)構(gòu)造一個(gè)service實(shí)例,并通過(guò)electron-re提供的MessageChannel通信工具在主進(jìn)程蠕嫁、渲染進(jìn)程锨天、service進(jìn)程之間任意發(fā)送消息,可以參考以下示例代碼:

  • 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 文件上傳架構(gòu)


文件上傳主要邏輯控制部分是前端的JS腳本代碼剃毒,位于主窗口所在的render渲染進(jìn)程绍绘,負(fù)責(zé)用戶獲取系統(tǒng)目錄文件、生成上傳任務(wù)隊(duì)列迟赃、動(dòng)態(tài)展示上傳任務(wù)列表詳情陪拘、任務(wù)列表的增刪查改等;主進(jìn)程Electron端的Node.js代碼主要負(fù)責(zé)響應(yīng)render進(jìn)程的控制命令進(jìn)行文件上傳任務(wù)隊(duì)列數(shù)據(jù)的增刪查改纤壁、上傳任務(wù)在內(nèi)存和磁盤的同步左刽、文件系統(tǒng)的交互、系統(tǒng)原生組件調(diào)用等酌媒。

文件上傳源和上傳目標(biāo)

  • 在用戶界面上使用Input組件獲取到的FileList(HTML5 API欠痴,用于web端的簡(jiǎn)單文件操作)即為上傳源;

  • 上傳目標(biāo)地址是遠(yuǎn)端集群某個(gè)節(jié)點(diǎn)的smb服務(wù)秒咨,因?yàn)镹ode.js NPM生態(tài)對(duì)smb的支持有限喇辽,目前并未發(fā)現(xiàn)一個(gè)可以支持通過(guò)smb協(xié)議進(jìn)行文件分片上傳的npm庫(kù),所以考慮使用Node.js的FS API進(jìn)行文件分段讀取然后將分片數(shù)據(jù)逐步增量寫入目標(biāo)地址來(lái)模擬文件分片上傳過(guò)程雨席,從而實(shí)現(xiàn)在界面上單個(gè)大文件上傳任務(wù)的啟動(dòng)菩咨、暫停、終止和續(xù)傳等操作陡厘,所以這里的解決方案是使用Windows UNC命令連接后端共享后抽米,可以像訪問(wèn)本地文件系統(tǒng)一樣訪問(wèn)遠(yuǎn)程一個(gè)遠(yuǎn)程smb共享路徑,比如文件路徑\\[host]\[sharename]\file1上的file1在執(zhí)行了unc連接后就可以通過(guò)Node.js FS API進(jìn)行操作糙置,跟操作本地文件完全一致云茸。整個(gè)必須依賴smb協(xié)議的上傳流程即精簡(jiǎn)為將本地拿到的文件數(shù)據(jù)復(fù)制到可以在本地訪問(wèn)的另一個(gè)smb共享路徑這一流程,而這一切都得益于Windows UNC命令谤饭。

/* 使用unc命令連接遠(yuǎn)程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
        });
      });
    });
  }

上傳流程概述

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

shards_upload.jpg
  1. 頁(yè)面上使用<Input />組件拿到FileList對(duì)象(Electron環(huán)境下拿到的File對(duì)象會(huì)額外附加一個(gè)path屬性指明文件位于系統(tǒng)的絕對(duì)路徑)
  2. 緩存拿到的FileList标捺,等待點(diǎn)擊上傳按鈕后開始讀取FileList列表并生成自定義的File文件對(duì)象數(shù)組用于存儲(chǔ)上傳任務(wù)列表信息
  3. 頁(yè)面調(diào)用init請(qǐng)求附帶上選中的文件信息初始化文件上傳任務(wù)
  4. Node.js拿到init請(qǐng)求附帶的文件信息后,將所有信息存入臨時(shí)存放在內(nèi)存中的文件上傳列表中揉抵,并嘗試打開待上傳文件的文件描述符用于即將開始的文件切片分段上傳工作亡容,最后返回給頁(yè)面上傳任務(wù)ID,Node.js端完成初始化處理
  5. 頁(yè)面拿到init請(qǐng)求成功的回調(diào)后功舀,存儲(chǔ)返回的上傳任務(wù)ID萍倡,并將該文件加入文件待上傳隊(duì)列,在合適的時(shí)機(jī)開始上傳辟汰,開始上傳的時(shí)候向Node.js端發(fā)送upload請(qǐng)求列敲,同時(shí)請(qǐng)求附帶上任務(wù)ID和當(dāng)前的分片索引值(表示需要上傳第幾個(gè)文件分片)
  6. Node.js拿到upload請(qǐng)求后根據(jù)攜帶的任務(wù)ID讀取內(nèi)存中的上傳任務(wù)信息阱佛,然后使用第二步打開的文件描述符和分片索引對(duì)本地磁盤中的目標(biāo)文件進(jìn)行分片切割,最后使用FS API將分片遞增寫入目標(biāo)位置戴而,即本地可直接訪問(wèn)的SMB共享路徑
  7. upload請(qǐng)求成功后頁(yè)面判斷是否已經(jīng)上傳完所有分片凑术,如果完成則向Node.js發(fā)送complete請(qǐng)求,同時(shí)攜帶上任務(wù)ID
  8. Node.js根據(jù)任務(wù)ID獲取文件信息所意,關(guān)閉文件描述符淮逊,更新文件上傳任務(wù)為上傳完成狀態(tài)
  9. 界面上傳任務(wù)列表全部完成后,向后端發(fā)送sync請(qǐng)求扶踊,把當(dāng)前任務(wù)上傳列表同步到歷史任務(wù)(磁盤存儲(chǔ))中泄鹏,表明當(dāng)前列表中所有任務(wù)已經(jīng)完成
  10. Node.js拿到sync請(qǐng)求后,把內(nèi)存中存儲(chǔ)的所有文件上傳列表信息寫入磁盤秧耗,同時(shí)釋放內(nèi)存占用备籽,完成一次列表任務(wù)上傳

Node.js實(shí)現(xiàn)的文件分片管理工廠

  • 文件初始化的時(shí)候調(diào)用open方法臨時(shí)存儲(chǔ)文件描述符和文件絕對(duì)路徑的映射關(guān)系;
  • 文件上傳的時(shí)候調(diào)用read方法根據(jù)文件讀取位置分井、讀取容量大小進(jìn)行分片切割车猬;
  • 文件上傳完成的時(shí)候調(diào)用close關(guān)閉文件描述符;

三個(gè)方法均通過(guò)文件絕對(duì)路徑path參數(shù)建立關(guān)聯(lián):

/**
  * 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
              }
            });
          }
          // 打開文件描述符珠闰,建議絕對(duì)路徑和fd的映射關(guān)系
          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()
          });
        }
      });
    },

    /* 關(guān)閉文件描述符 */
    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的文件上傳卡頓優(yōu)化踩坑


優(yōu)化是一件頭大的事兒瘫辩,因?yàn)槟阈枰韧ㄟ^(guò)很多測(cè)試手法找到現(xiàn)有代碼的性能瓶頸伏嗜,然后編寫優(yōu)化解決方案。我覺(jué)得找到性能瓶頸這一點(diǎn)就特別難杭朱,因?yàn)槭亲约簩懙拇a所以容易陷入一些先入為主的刻板思考模式阅仔。不過(guò)最最主要的一點(diǎn)還是你如果自己都弄不清楚你使用的技術(shù)棧的話吹散,那就無(wú)從談起優(yōu)化弧械,所以前面有很大篇幅分析了Electron進(jìn)程方面的知識(shí)以及梳理了整個(gè)上傳流程。

使用Electron自帶的Devtools進(jìn)行性能分析

在文件上傳過(guò)程中打開性能檢測(cè)工具Performance進(jìn)行錄制空民,分析整個(gè)流程:

upload_performance.jpg

在文件上傳過(guò)程中打開內(nèi)存工具Memory進(jìn)行快照截取分析一個(gè)時(shí)刻的內(nèi)存占用情況:

upload_memory.jpg

第一次嘗試解決問(wèn)題:替換Antd Table組件

在編寫完成文件上傳模塊后刃唐,初步進(jìn)行了壓力測(cè)試,結(jié)果發(fā)現(xiàn)添加1000個(gè)文件上傳任務(wù)到任務(wù)隊(duì)列界轩,且同時(shí)上傳的文件上傳任務(wù)數(shù)量為6時(shí)画饥,上下滑動(dòng)查看文件上傳列表時(shí)出現(xiàn)了卡頓的情況,這種卡頓不局限于某個(gè)界面組件的卡頓浊猾,而且當(dāng)前窗口的所有操作都卡了起來(lái)抖甘,初步懷疑是Antd Table組件引起的卡頓,因?yàn)锳ntd Table組件是個(gè)很復(fù)雜的高階組件葫慎,在處理大量的數(shù)據(jù)時(shí)可能會(huì)有性能問(wèn)題衔彻,遂我將Antd Table組件換成了原生的table組件薇宠,且Table列表只顯示每個(gè)上傳任務(wù)的任務(wù)名,其余的諸如上傳進(jìn)度這些都不予顯示艰额,從而想避開這個(gè)問(wèn)題澄港。令人吃驚的是測(cè)試結(jié)果是即使換用了原生Table組件,卡頓情況仍然毫無(wú)改善柄沮!

第二次嘗試解決問(wèn)題:改造Electron主進(jìn)程同步阻塞代碼

先看下chromium的架構(gòu)圖回梧,每個(gè)渲染進(jìn)程都有一個(gè)全局對(duì)象RenderProcess,用來(lái)管理與父瀏覽器進(jìn)程的通信祖搓,同時(shí)維護(hù)著一份全局狀態(tài)狱意。瀏覽器進(jìn)程為每個(gè)渲染進(jìn)程維護(hù)一個(gè)RenderProcessHost對(duì)象,用來(lái)管理瀏覽器狀態(tài)和與渲染進(jìn)程的通信拯欧。瀏覽器進(jìn)程和渲染進(jìn)程使用Chromium的IPC系統(tǒng)進(jìn)行通信髓涯。在chromium中,頁(yè)面渲染時(shí)哈扮,UI進(jìn)程需要和main process不斷的進(jìn)行IPC同步纬纪,若此時(shí)main process忙,則UIprocess就會(huì)在IPC時(shí)阻塞滑肉。

chromium.jpg

綜上所述:如果主進(jìn)程持續(xù)進(jìn)行消耗CPU時(shí)間的任務(wù)或阻塞同步IO的任務(wù)的話包各,主進(jìn)程就會(huì)在一定程度上阻塞,從而影響主進(jìn)程和各個(gè)渲染進(jìn)程之間的IPC通信靶庙,IPC通信有延遲或是受阻问畅,自然渲染界面的UI繪制和更新就會(huì)呈現(xiàn)卡頓的狀態(tài)。

我分析了一下Node.js端的文件任務(wù)管理的代碼邏輯六荒,把一些操作諸如獲取文件大小护姆、獲取文件類型和刪除文件這類的同步阻塞IO調(diào)用都換成了Node.js提倡的異步調(diào)用模式,即FS callback或Fs Promise鏈?zhǔn)秸{(diào)用掏击。改動(dòng)后發(fā)現(xiàn)卡頓情況改善不明顯卵皂,遂進(jìn)行了第三次嘗試。

第三次嘗試解決問(wèn)題:編寫Node.js進(jìn)程池分離上傳任務(wù)管理邏輯

這次是大改??

1. 簡(jiǎn)單實(shí)現(xiàn)了node.js進(jìn)程池
源碼:ChildProcessPool.class.js砚亭,主要邏輯是使用Node.js的child_process模塊(具體使用請(qǐng)看文檔) 創(chuàng)建指定數(shù)量的多個(gè)子進(jìn)程灯变,外部通過(guò)進(jìn)程池獲取一個(gè)可用的進(jìn)程,在進(jìn)程中執(zhí)行需要的代碼邏輯捅膘,而在進(jìn)程池內(nèi)部其實(shí)就是按照順序依次將已經(jīng)創(chuàng)建的多個(gè)子進(jìn)程中的某一個(gè)返回給外部調(diào)用即可添祸,從而避免了其中某個(gè)進(jìn)程被過(guò)度使用,省略代碼如下:

...
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方法向子進(jìn)程發(fā)送消息寻仗,前者是向某個(gè)進(jìn)程發(fā)送刃泌,如果請(qǐng)求參數(shù)指定了id則表明需要明確使用之前與此id建立過(guò)映射的某個(gè)進(jìn)程,并期望拿到此進(jìn)程的回應(yīng)結(jié)果;后者是向進(jìn)程池中的所有進(jìn)程發(fā)送信號(hào)耙替,并期望拿到所有進(jìn)程返回的結(jié)果(==供調(diào)用者外部調(diào)用==)鲤遥。

  • 2)其中dataResponddataRespondAll方法對(duì)應(yīng)上面的兩個(gè)信號(hào)發(fā)送方法的進(jìn)程返回?cái)?shù)據(jù)回調(diào)函數(shù),前者拿到進(jìn)程池中指定的某個(gè)進(jìn)程的回調(diào)結(jié)果林艘,后者拿到進(jìn)程池中所有進(jìn)程的回調(diào)結(jié)果(==進(jìn)程池內(nèi)部方法盖奈,調(diào)用者無(wú)需關(guān)注==)。

  • 3)getForkedFromPool方法是從進(jìn)程池中拿到一個(gè)進(jìn)程狐援,如果進(jìn)程池還沒(méi)有一個(gè)子進(jìn)程或是已經(jīng)創(chuàng)建的子進(jìn)程數(shù)量小于設(shè)置的可創(chuàng)建子進(jìn)程數(shù)最大值钢坦,那么會(huì)優(yōu)先新創(chuàng)建一個(gè)子進(jìn)程放入進(jìn)程池,然后返回這個(gè)子進(jìn)程以供調(diào)用(==進(jìn)程池內(nèi)部方法啥酱,調(diào)用者無(wú)需關(guān)注==)爹凹。

  • 4)getForkedFromPool方法中值得注意的是這行代碼:this.env.NODE_ENV === "development" ? [`--inspect=${this.inspectStartIndex}`] : [],使用Node.js運(yùn)行js腳本時(shí)加上- -inspect=端口號(hào) 參數(shù)可以開啟所運(yùn)行進(jìn)程的遠(yuǎn)程調(diào)試端口镶殷,多進(jìn)程程序狀態(tài)追蹤往往比較困難禾酱,所以采取這種方式后可以使用瀏覽器Devtools單獨(dú)調(diào)試每個(gè)進(jìn)程(具體可以在瀏覽器輸入地址:chrome://inspect/#devices然后打開調(diào)試配置項(xiàng),配置我們這邊指定的調(diào)試端口號(hào)绘趋,最后點(diǎn)擊藍(lán)字Open dedicated DevTools for Node就能打開一個(gè)調(diào)試窗口颤陶,可以對(duì)代碼進(jìn)程斷點(diǎn)調(diào)試、單步調(diào)試陷遮、步進(jìn)步出滓走、運(yùn)行變量查看等操作,十分便利帽馋!)搅方。

inspect.jpg

2. 分離子進(jìn)程通信邏輯和業(yè)務(wù)邏輯
另外被作為子進(jìn)程執(zhí)行文件載入的js文件中可以使用我封裝的ProcessHost.class.js,我把它稱為進(jìn)程事務(wù)管理中心绽族,主要功能是使用api諸如 - ProcessHost.registry(taskName, func)來(lái)注冊(cè)多種任務(wù)姨涡,然后在主進(jìn)程中可以直接使用進(jìn)程池獲取某個(gè)進(jìn)程后向某個(gè)任務(wù)發(fā)送請(qǐng)求并取得Promise對(duì)象以拿到進(jìn)程回調(diào)返回的數(shù)據(jù),從而避免在我們的子進(jìn)程執(zhí)行文件中編寫代碼時(shí)過(guò)度關(guān)注進(jìn)程之間數(shù)據(jù)的通信吧慢。
如果不使用進(jìn)程事務(wù)管理中心的話我們就需要使用process.send來(lái)向一個(gè)進(jìn)程發(fā)送消息并在另一個(gè)進(jìn)程中使用process.on('message', processor)處理消息涛漂。需要注意的是如果注冊(cè)的task任務(wù)是異步的則需要返回一個(gè)Promise對(duì)象而不是直接return數(shù)據(jù),簡(jiǎn)略代碼如下:

  • 1)registry用于子進(jìn)程向事務(wù)中心注冊(cè)自己的任務(wù)
  • 2)unregistry用于取消任務(wù)注冊(cè)
  • 3)handleMessage處理進(jìn)程接收到的消息并根據(jù)action參數(shù)調(diào)用某個(gè)任務(wù)
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的配合使用
具體使用請(qǐng)查看上文完整demo
1)main.js (in main process)
主進(jìn)程中引入進(jìn)程池類娄蔼,并創(chuàng)建進(jìn)程池實(shí)例

  • |——path參數(shù)為可執(zhí)行文件路徑
  • |——max指明進(jìn)程池創(chuàng)建的最大子進(jìn)程實(shí)例數(shù)量
  • |——env為傳遞給子進(jìn)程的環(huán)境變量
/* 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) 例子:使用進(jìn)程池來(lái)發(fā)送初始化分片上傳請(qǐng)求

 /**
    * init [初始化上傳]
    * @param  {[String]} host [主機(jī)名]
    * @param  {[String]} username [用戶名]
    * @param  {[Object]} file [文件描述對(duì)象]
    * @param  {[String]} abspath [絕對(duì)路徑]
    * @param  {[String]} sharename [共享名]
    * @param  {[String]} fragsize [分片大小]
    * @param  {[String]} prefix [目標(biāo)上傳地址前綴]
    */
  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(
            /* 進(jìn)程事務(wù)名 */
            'init-works',
            /* 攜帶的參數(shù) */
            {
              username, host, sharename, pre, prefix, size: file.size, name: file.name, abspath, fragsize, record: 
              {
                host, // 主機(jī)
                filename: path.join(prefix, file.name), // 文件名
                size, // 文件大小
                fragsize, // 分片大小
                abspath, // 絕對(duì)路徑
                startime: getTime(new Date().getTime()), // 上傳日期
                endtime: '', // 上傳日期
                uploadId, // 任務(wù)id
                index: 0,
                total: Math.ceil(size / fragsize),
                status: 'uploading' // 上傳狀態(tài)
              }
            },
            /* 指定一個(gè)進(jìn)程調(diào)用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) 使用事務(wù)管理中心處理消息
child.js即為創(chuàng)建進(jìn)程池時(shí)傳入的path參數(shù)所在的nodejs腳本代碼,在此腳本中我們注冊(cè)多個(gè)任務(wù)來(lái)處理從進(jìn)程池發(fā)送過(guò)來(lái)的消息岁诉。
這段代碼邏輯被單獨(dú)分離到子進(jìn)程中處理,其中:

  • uploadStore - 主要用于在內(nèi)存中維護(hù)整個(gè)文件上傳列表跋选,對(duì)上傳任務(wù)列表進(jìn)行增刪查改操作(cpu耗時(shí)操作)
  • fileBlock - 利用FS API操作文件涕癣,比如打開某個(gè)文件的文件描述符、根據(jù)描述符和分片索引值讀取一個(gè)文件的某一段Buffer數(shù)據(jù)、關(guān)閉文件描述符等等坠韩。雖然都是異步IO讀寫距潘,對(duì)性能影響不大,不過(guò)為了整合nodejs端上傳處理流程也將其一同納入了子進(jìn)程中管理只搁,具體可以查看源碼進(jìn)行了解:源碼
  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());
     });
    })
  }

  ...

第四次嘗試解決問(wèn)題:重新審視渲染進(jìn)程前端代碼

  • 很遺憾音比,第三次優(yōu)化對(duì)卡頓的改善依然不明顯,我開始懷疑是否是前端代碼直接影響的渲染進(jìn)程卡頓氢惋,畢竟前端并非采用懶加載模式進(jìn)行文件載入上傳的(這一懷疑之前被我否定洞翩,因?yàn)榍岸舜a完全沿用了之前瀏覽器端對(duì)象存儲(chǔ)文件分片上傳開發(fā)時(shí)的邏輯,而在對(duì)象存儲(chǔ)文件上傳中并未察覺(jué)到界面卡頓焰望,屬實(shí)奇怪)骚亿。摒棄了先入為主的思想,其實(shí)Electron跟瀏覽器環(huán)境還是有些不同熊赖,不能排除前端代碼就沒(méi)有問(wèn)題来屠。
  • 在詳細(xì)查看了可能耗費(fèi)CPU計(jì)算的代碼邏輯后,發(fā)現(xiàn)有一段關(guān)于刷新上傳任務(wù)的函數(shù)refreshTasks震鹉,主要邏輯是遍歷所有未經(jīng)上傳文件原始對(duì)象數(shù)組俱笛,然后選取固定某個(gè)數(shù)量的文件(數(shù)量取決于設(shè)置的同時(shí)上傳任務(wù)個(gè)數(shù))放入待上傳文件列表中,我發(fā)現(xiàn)如果待上傳文件列表的文件數(shù)量 = 設(shè)置的同時(shí)上傳任務(wù)個(gè)數(shù) 的情況下就不用繼續(xù)遍歷剩下的文件原始對(duì)象數(shù)組了传趾。就是少寫了這個(gè)判斷條件導(dǎo)致refreshTasks這個(gè)頻繁操作的函數(shù)在每次執(zhí)行時(shí)可能多執(zhí)行數(shù)千遍for循環(huán)內(nèi)層判斷邏輯(具體執(zhí)行次數(shù)呈O(n)次增長(zhǎng)嫂粟,n為當(dāng)前任務(wù)列表任務(wù)數(shù)量)。
  • 加上一行檢測(cè)邏輯代碼后墨缘,之前1000個(gè)上傳任務(wù)增長(zhǎng)到10000個(gè)左右都不會(huì)太卡了星虹,雖然還有略微卡頓,但沒(méi)有到不能使用的程度镊讼,后續(xù)還有優(yōu)化空間宽涌!
refreshTasks.jpg

總結(jié)


第一次把Electron技術(shù)應(yīng)用到實(shí)際項(xiàng)目中,踩了挺多坑:render進(jìn)程和主進(jìn)程通信的問(wèn)題蝶棋、跨平臺(tái)兼容的問(wèn)題卸亮、多平臺(tái)打包的問(wèn)題、窗口管理的問(wèn)題... 總之獲得了很多經(jīng)驗(yàn)玩裙,也整理出了一些通用解決方法兼贸。
Electron現(xiàn)在應(yīng)用的項(xiàng)目還是挺多的,是前端同學(xué)跨足桌面軟件開發(fā)領(lǐng)域的又一里程碑吃溅,不過(guò)需要轉(zhuǎn)換一下思維模式溶诞,單純寫前端代碼多是處理一些簡(jiǎn)單的界面邏輯和少量的數(shù)據(jù),涉及到文件决侈、系統(tǒng)操作螺垢、進(jìn)程線程、原生交互方面的知識(shí)比較少,可以多了解一下計(jì)算機(jī)操作系統(tǒng)方面的知識(shí)枉圃、掌握代碼設(shè)計(jì)模式和一些基本的算法優(yōu)化方面的知識(shí)能讓你更加勝任Electron桌面軟件開發(fā)任務(wù)功茴!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市孽亲,隨后出現(xiàn)的幾起案子坎穿,更是在濱河造成了極大的恐慌,老刑警劉巖返劲,帶你破解...
    沈念sama閱讀 218,682評(píng)論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件玲昧,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡旭等,警方通過(guò)查閱死者的電腦和手機(jī)酌呆,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,277評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)搔耕,“玉大人隙袁,你說(shuō)我怎么就攤上這事∑ィ” “怎么了菩收?”我有些...
    開封第一講書人閱讀 165,083評(píng)論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)鲸睛。 經(jīng)常有香客問(wèn)我娜饵,道長(zhǎng),這世上最難降的妖魔是什么官辈? 我笑而不...
    開封第一講書人閱讀 58,763評(píng)論 1 295
  • 正文 為了忘掉前任箱舞,我火速辦了婚禮,結(jié)果婚禮上拳亿,老公的妹妹穿的比我還像新娘晴股。我一直安慰自己,他們只是感情好肺魁,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,785評(píng)論 6 392
  • 文/花漫 我一把揭開白布电湘。 她就那樣靜靜地躺著延曙,像睡著了一般廓八。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上伦籍,一...
    開封第一講書人閱讀 51,624評(píng)論 1 305
  • 那天瘾晃,我揣著相機(jī)與錄音贷痪,去河邊找鬼。 笑死酗捌,一個(gè)胖子當(dāng)著我的面吹牛呢诬,可吹牛的內(nèi)容都是我干的涌哲。 我是一名探鬼主播胖缤,決...
    沈念sama閱讀 40,358評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼尚镰,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了哪廓?” 一聲冷哼從身側(cè)響起狗唉,我...
    開封第一講書人閱讀 39,261評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎涡真,沒(méi)想到半個(gè)月后分俯,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,722評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡哆料,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,900評(píng)論 3 336
  • 正文 我和宋清朗相戀三年缸剪,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片东亦。...
    茶點(diǎn)故事閱讀 40,030評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡杏节,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出典阵,到底是詐尸還是另有隱情奋渔,我是刑警寧澤,帶...
    沈念sama閱讀 35,737評(píng)論 5 346
  • 正文 年R本政府宣布壮啊,位于F島的核電站嫉鲸,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏歹啼。R本人自食惡果不足惜玄渗,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,360評(píng)論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望狸眼。 院中可真熱鬧藤树,春花似錦、人聲如沸份企。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,941評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)司志。三九已至甜紫,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間骂远,已是汗流浹背囚霸。 一陣腳步聲響...
    開封第一講書人閱讀 33,057評(píng)論 1 270
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留激才,地道東北人拓型。 一個(gè)月前我還...
    沈念sama閱讀 48,237評(píng)論 3 371
  • 正文 我出身青樓额嘿,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親劣挫。 傳聞我的和親對(duì)象是個(gè)殘疾皇子册养,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,976評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容