Puppeteer+RabbitMQ:Node.js 批次加工pdf服務架構設計與落地

JunpengZ發表於2023-04-19

智慧作業最近上線「個性化手冊」(簡稱個冊)功能,一份完整的個性化手冊分為三部分

  • 學情分析:根據學生階段性的學習和考試情況進行學情分析、歸納、總結,彙總學情資料;
  • 精準推薦:推薦演算法基於學情資料結合知識圖譜進行精準練習題推薦;
  • 錯題回顧:錯題的階段性回顧複習。
    第一部分學情分析的PDF由Node.js加工,與Java後端透過訊息佇列RabbitMQ進行資料互動,本文簡單記錄一下Node.js批次加工PDF服務的架構模式,以及基於現階段發現的問題,梳理未來的迭代規劃和演進方向。

業務特徵

個冊三個部分的PDF資料來源不同,生產邏輯獨立由不同的服務生產,最終將三份PDF合併為一份,還要支援班級所有學生批次生產和壓縮打包,所以這個功能在技術角度最主要的特徵就是環節多耗時長

  • 環節多意味著在各個服務之間存在較多的網路通訊和資料互動,核心挑戰在於如何設計低耦合、高可用的服務架構;
  • 耗時長一方面體現在多個環節的總耗時,另一方面體現在三個PDF生產服務各自的加工耗時。
    基於以上業務特徵,PDF加工服務架構設計的一個大方向就是將長耗時任務非同步處理,各服務之間邏輯解耦,透過訊息佇列進行資料互動

技術選型

服務端生成PDF通常有兩種方案:

  • 第一種是使用 pdfkit 之類的工具透過程式碼繪製,這種方案最大的問題是可渲染的內容型別有限,定製化不足;
  • 第二種是建立 headless browser用html渲染後擷取pdf,這種方案的架構相對複雜,但是可以支援所有web端的內容型別。
    個冊第一部分學情分析的某一頁長這個樣子:

僅這一頁就涵蓋文字、表格、圖表以及各種自定義圖案,內容型別多樣並且後續迭代可能增加更多定製化內容,第一種方案的侷限性很難滿足需求,所以最終選定 headless browser 方案。

具體到 headless browser 的技術選型就非常有限了,可選的無非就是 Selenium/PhantomJS 這類老招牌,或者 Puppeteer/Playwright 這類新玩家。

嚴格來說Selenium只是一種類似按鍵精靈的工具,可透過程式碼在瀏覽器中模擬人的操作,本身並不是瀏覽器,所以需要搭配第三方瀏覽器使用,比如PhantomJS。
Selenium/PhantomJS 的最大的優點就是生態健全,支援多種程式語言,有相對繁榮的技術社群;缺點就是穩定性和效能較差,Selenium的穩定性出了名的糟糕,PhantomJS五年前就停止維護了。這哥倆通常用在對穩定性要求不是很高的場景,比如爬蟲。

與之形成鮮明對比的,Puppeteer/Playwright 最大的優點就是穩定性高,效能更優;缺點就是對程式語言的支援有限,生態和技術社群相對沒那麼健全。

個冊的業務特徵一是對穩定性和效能要求很高;二是不要求跨瀏覽器(Playwright支援瀏覽器型別更豐富)。最終綜合考慮API易用性、穩定性、效能、社群、風險等因素,在 Puppeteer 和 Playwright 之間選擇了 Puppeteer。既然選定了 Puppeteer,配套的自然就是 Node.js了。

Puppeteer 和 Playwright 的對比可以參考這篇文章:Playwright vs Puppeteer: Core Differences
這個需求是我第一次使用Puppeteer,還沒完全摸透,下文涉及到Puppeteer相關的方案如果有問題,歡迎討論指點

實現方案

智慧教育的分層架構如下:

Node.js PDF服務是本次需求新增的,為了方便分離部署和最佳化,PDF服務單獨建立一個服務,不涉及Node.js接入層的改動。下圖是個冊PDF加工的完整流程:

每個環節的具體流程不細講,Node.js PDF加工服務的細節下文詳解。與Node.js PDF服務相關最關鍵的是與Java後端的資料互動流程。Java後端與Node.js PDF服務透過 RabbitMQ 訊息佇列進行資料互動,建立兩個佇列

佇列 生產者 消費者 說明
任務佇列 Java後端 Node.js PDF服務 Java 向佇列中傳送個冊渲染資料,Node.js 消費
回傳佇列 Node.js PDF服務 Java後端 Node.js 向佇列中傳送pdf加工結果資料,Java 消費

這部分沒啥好講的,Node.js與Java之間按照約定的資料規範組裝資料即可,下面詳細介紹一下Node.js加工pdf的具體邏輯。

這一版個冊的第一部分學情分析控制在3頁,早期規劃的個冊PDF大約25頁左右,技術調研和架構設計都是基於這個預期進行的,所以現在這套模式多少有點殺雞用牛刀的意思,不過前期打好基礎給後續迭代留些空間也是好事。

單份PDF加工流程

為了更方便理解,在介紹pdf加工流程之前,有必要先簡要一下Node.js PDF服務的架構,以及與PDF加工邏輯最相關的 worker角色。

Node.js PDF服務架構最核心的三個角色:

  • Scheduler:負責輪詢排程,發起任務;
  • Executor:負責任務前置後置相關邏輯,包括worker pool管理、worker 排程、MQ任務佇列訊息拉取、MQ回傳佇列訊息傳送等;
  • Worker:負責實質執行任務,包括pdf渲染、生產、上傳OSS;
    三者的關係如下所示:

SchedulerExecutor的具體邏輯以及三個角色之間的排程邏輯下文再詳解,PDF檔案的實質生產邏輯都集中在 Worker中,流程如下:

圖中「傳送訊息至MQ回傳佇列」實質是由 Executor執行,此處畫出方便理解完整流程。

預啟動

圖中虛線部分的預啟動是在啟動 Node.js 服務之前執行的邏輯,預啟動完成之後 Node.js 服務被拉起,所以預啟動的耗時是一次性的。

預啟動過程執行兩個動作:

  • 讀取磁碟中的html檔案內容,寫入記憶體,為後續環節「載入網頁」提供資料;
  • 建立 Puppeteer browser 例項。

上圖中只畫出pdf加工邏輯相關的預啟動工作,實際上預啟動還包含一些其他邏輯,比如建立 MQ 連線通道。

冷啟動(廢棄)

雖然冷啟動在後來開發過程中被廢棄,但透過這個事情發現自己的不足,還是值得記錄一下的。
最初之所以設想冷啟動環節,是因為嘗試用 worker 模擬多執行緒。每個worker會建立一個browser例項和多個page例項(目前是3個),如下所示:

這樣做的目的是將每個worker的負載上限固定,便於伺服器資源規模預估,避免伺服器某個節點負載過高,進而也可以避免k8s叢集pod的縱向伸縮。

k8s縱向伸縮的取捨見仁見智,我個人不太建議使用。
如果任務佇列長時間為空會觸發快取清理邏輯,銷燬browser和page例項以節省伺服器資源,再次發起任務會觸發冷****啟動。冷啟動執行兩件事情:

  • 連結/建立browser例項
  • 建立page例項
    另外增加一個標識位_mounted代表冷啟動是否完成,程式碼如下:
public async run(){
  if(!this._mounted){
    // 觸發冷啟動
    this._mount();
  }
  // ...其他邏輯
}
private async _mount(){
    if(!this._browser?.isConnected()){
      // 連結browser
      this._browser = await puppeteer.connect({
        browserWSEndpoint: this._wsEndpoint
      });
    }
    // 建立page例項
    if(isEmpty(this._pages)){
      for(let i =0;i<this._opts.maxPageCount;i++){
        const ctx = await this._browser.newPage();
        this._pages.push({
          ctx,
          busy: false
        });
      }
    }

    this._mounted = true;
}

乍看起來似乎沒啥問題,但實際跑一跑程式碼會發現,在任務排程密集的時候,run函式短時間內被呼叫多次(具體的排程策略下文講解),worker會觸發多次冷啟動,雖然不影響業務邏輯,但會引起伺服器資源暴漲,這是因為冷啟動會建立新的browser和page例項,但是舊例項並沒有被清理,仍然在執行任務。
冷啟動被呼叫多次的根本原因是Node.js不是多執行緒,如下圖所示,假設冷啟動耗時20ms,在此期間再次呼叫run函式,標識位_mounted還未被設定為true,就會又觸發一次冷啟動。

有沒有解法?

當然有。多執行緒程式設計解決競態最常用的就是:加鎖。既然想模擬多執行緒那就徹底一點,把鎖邏輯也加上唄。

worker本身是有“鎖”的,每個worker有3個page例項,只有當存在空閒例項(busy為false)時run函式才可以執行,但是這個鎖機制並不能避免多次冷啟動問題,因為冷啟動完成之前page例項還未被建立。

可能會有人說,那就加個限制,page例項不存在時也不讓run函式執行不就得了?這麼做的話run函式永遠都不會被執行啊大聰明。

既然worker已有的鎖不行,那就再加個冷啟動鎖,冷啟動之前鎖定,冷啟動之後解鎖。這麼做當然是可以的,但是會增加邏輯複雜度,worker有兩種鎖,對後期迭代維護無疑是埋雷。

其實之所以有冷啟動無非就是為了省點記憶體,用時間換空間,一個browser例項+3個空白page例項總共100m左右的記憶體,這年頭記憶體這麼便宜,為了省這點空間把邏輯搞那麼複雜完全得不償失。什麼叫過度設計,這就是過度設計。

所以後來索性把冷啟動過程幹掉了,browser和page例項的建立放在worker初始化邏輯裡。

public async init() {
    /**
     * 儘量禁用掉不需要的功能,提高效能
     */
    this._browser = await puppeteer.launch({
      headless: true,
      args: [
        '--incognito',
        '--disable-gpu',
        '--disable-dev-shm-usage',
        '--disable-setuid-sandbox',
        '--no-first-run',
        '--no-sandbox',
        '--no-zygote',
        '--single-process'
      ]
    });

    this._wsEndpoint = this._browser.wsEndpoint();
    // _mount函式邏輯不改動,呼叫_mount函式放在初始化邏輯中
    await this._mount();
}

載入網頁

網頁透過page.setContent(html)函式載入本地html檔案,與透過page.goto(url)載入遠端URL相比,既節省了部署網頁的伺服器資源,同時速度也更快

時間消耗 執行時機 效能瓶頸 其他
遠端URL
  • DNS耗時
  • 下載耗時
  • 解析html耗時
執行時 網路IO 非同步下載html引用的靜態資源會增加額外耗時
本地html
  • 讀磁碟耗時
  • 解析html耗時
預啟動階段 檔案IO+常駐記憶體

上文提到過,本地html檔案在預啟動階段提前從磁碟讀取存放於記憶體,執行時無需實時讀取。所以檔案IO的耗時不算在pdf加工邏輯總耗時中,而載入遠端URL只能在執行時執行,會增長pdf加工的總時長。

另外,載入的本地html檔案中不能存在靜態資源引用,比如js和css必須全部以行內

相關文章