happypack 原理解析

發表於2016-12-10

tb15zejofxxxxcyxvxxxxxxxxxx-900-500

說起 happypack 可能很多同學還比較陌生,其實 happypack 是 webpack 的一個外掛,目的是通過多程式模型,來加速程式碼構建,目前我們的線上伺服器已經上線這個外掛功能,並做了一定適配,效果顯著。這裡有一些大致參考:

tb1apiaofxxxxcnxvxxxxxxxxxx-549-451

這張圖是 happypack 九月逐步全量上線後構建時間的的參考資料,線上構建伺服器 16 核環境。

在上這個外掛的過程中,我們也發現了這個單人維護的社群外掛有一些問題,我們在解決這些問題的同時,也去修改了內部的程式碼,釋出了自己維護的版本 @ali/happypack,那麼內部是怎麼跑起來的,這裡做一個總結記錄。

webpack 載入配置

這個示例只單獨抽取了配置 happypack 的部分。可以看到,類似 extract-text-webpack-plugin 外掛,happypack 也是通過 webpack 中 loaderplugin 的相互呼叫協作的方式來運作。

loader 配置直接指向 happypack 提供的 loader, 對於檔案實際匹配的處理 loader ,則是通過配置在 plugin 屬性來傳遞說明,這裡 happypack 提供的 loader 與 plugin 的銜接匹配,則是通過 id=less 來完成。

happypack 檔案解析

HappyPlugin.js

tb1svtkofxxxxcaxpxxxxxxxxxx-767-269

對於 webpack 來講,plugin 是貫穿在整個構建流程,同樣對於 happypack 配置的構建流程,首先進入邏輯的是 plugin 的部分,從初始化的部分檢視 happypack 中與 plugin 關聯的檔案。

1. 基礎引數設定

對於基礎引數的初始化,對應上文提到的配置,可以看到外掛設定了兩個標識

  • id: 在配置檔案中設定的與 loader 關聯的 id 首先會設定到例項上,為了後續 loader 與 plugin 能進行一對一匹配
  • name: 標識外掛型別為 HappyPack,方便快速在 loader 中定位對應 plugin,同時也可以避免其他外掛中存在 id 屬性引起錯誤的風險

對於這兩個屬性的應用,可以看到 loader 檔案中有這樣一段程式碼

其次宣告 state 物件標識外掛的執行狀態之後,開始配置資訊的處理。

呼叫 OptionParser 函式來進行外掛過程中使用到的引數合併,在合併函式的引數物件中,提供了作為資料合併依據的一些屬性,例如合併型別 type、預設值 default 以及還有設定校驗函式的校驗屬性 validate 完成屬性檢查。

這裡對一些執行過車中的重要屬性進行解釋:

  • tmpDir: 存放打包快取檔案的位置
  • cache: 是否開啟快取,目前快取如果開啟,(注: 會以數量級的差異來縮短構建時間,很方便日常開發)
  • cachePath: 存放快取檔案對映配置的位置
  • verbose: 是否輸出過程日誌
  • loaders: 因為配置中檔案的處理 loader 都指向了 happypack 提供的 loadr ,這裡配置的對應檔案實際需要執行的 loader

2. 執行緒池初始化

這裡的 thread 其實嚴格意義說是 process,應該是程式,猜測只是套用的傳統軟體的一個主程式多個執行緒的模型。這裡不管是在配置中,配置的是 threads 屬性還是 threadPool 屬性,都會生成一個 HappyThreadPool 物件來管理生成的子程式物件。

2.1. HappyThreadPool.js

在返回 HappyThreadPool 物件之前,會有兩個操作:

2.1.1. HappyRPCHandler.js

對於 HappyRPCHandler 例項,可以從建構函式看到,會繫結當前執行的 loader 與 compiler ,同時在檔案中,針對 loader 與 compiler 定義呼叫介面:

  • 對應 compiler 會繫結查詢解析路徑的 reolve 方法:
  • 對應 loader 其中一些繫結:

通過定義呼叫 webpack 流程過程中的 loader、compiler 的能力來完成功能,類似傳統服務中的 RPC 過程。

2.1.2. 建立子程式 (HappyThread.js)

傳遞子程式數引數 config.size 以及之前生成的 HappyRPCHandler 物件,呼叫createThreads 方法生成實際的子程式。

fullThreadId 生成之後,傳入 HappyThread 方法,生成對應的子程式,然後放在 set 集合中返回。呼叫 HappyThread 返回的物件就是 Happypack 的編譯 worker 的上層控制。

物件中包含了對應的程式狀態控制 openclose,以及通過子程式來實現編譯的流程控制configurecompile

2.1.2.1 子程式執行檔案 HappyWorkerChannel.js

上面還可以看到一個資訊是,fd 子程式的執行檔案路徑變數 WORKER_BIN,這裡對應的是相同目錄下的 HappyWorkerChannel.js

精簡之後的程式碼可以看到 fork 子程式之後,最終執行的是 HappyWorkerChannel 函式,這裡的 stream 引數對應的是子程式的 process 物件,用來與主程式進行通訊。

函式的邏輯是通過 stream.on('messgae') 訂閱訊息,控制層 HappyThread 物件來傳遞訊息進入子程式,通過 accept() 方法來路由訊息進行對應編譯操作。

對於不同的上層訊息進行不通的子程式處理。

2.1.2.1.1 子程式編譯邏輯檔案 HappyWorker.js

這裡的核心方法 compile ,對應了一層 worker 抽象,包含 Happypack 的實際編譯邏輯,這個物件的建構函式對應 HappyWorker.js 的程式碼。

applyLoaders 的引數看到,這裡會把 webpack 編輯過程中的 loadersloaderContext通過最上層的 HappyPlugin 進行傳遞,來模擬實現 loader 的編譯操作。

從回撥函式中看到當編譯完成時, fs.writeFileSync(compiledPath, source); 會將編譯結果寫入 compilePath 這個編譯路徑,並通過 done 回撥返回編譯結果給主程式。

3. 編譯快取初始化

happypack 會將每一個檔案的編譯進行快取,這裡通過

這裡的 cachePath 預設會將 plugin 的 tmpDir 的目錄作為生成快取對映配置檔案的目錄路徑。同時建立好 config.tempDir 目錄。

3.1 happypack 快取控制 HappyFSCache.js
HappyFSCache 函式這裡返回對應的 cache 物件,在編譯的開始和 worker 編譯完成時進行快取載入、設定等操作。

對於編譯過程中的單個檔案,會通過 getCompiledSourceCodePath 函式來獲取對應的快取內容的檔案物理路徑,同時在新檔案編譯完整之後,會通過 updateMTimeFor 來進行快取設定的更新。

HappyLoader.js

在 happypack 流程中,配置的對應 loader 都指向了 happypack/loader.js ,檔案對應匯出的是 HappyLoader.js 匯出的物件 ,對應的 bundle 檔案處理都通過 happypack 提供的 loader 來進行編譯流程。

省略了部分程式碼,HappyLoader 首先拿到配置 id ,然後對所有的 webpack plugin 進行遍歷

找到 id 匹配的 happypackPlugin。傳遞原有 webpack 編譯提供的 loaderContext (loader 處理函式中的 this 物件)中的引數,呼叫 happypackPlugincompile 進行編譯。

上面是 happypack 的主要檔案,作者在專案介紹中也提供了一張圖來進行結構化描述:

tb12ji-opxxxxclaxxxxxxxxxxx-916-556

實際執行

從前面的檔案解析,已經把 happypack 的工程檔案關聯結構大致說明了一下,這下結合日常在構建工程的一個例子,將整個流程串起來說明。

啟動入口

tb1px8jofxxxxbdaxxxxxxxxxxx-1022-487

在 webpack 編譯流程中,在完成了基礎的配置之後,就開始進行編譯流程,這裡 webpack 中的 compiler 物件會去觸發 run 事件,這邊 HappypackPlugin 以這個事件作為流程入口,進行初始化。

run 事件觸發時,開始進行 start 整個流程

start函式通過 async.series 將整個過程串聯起來。

1. registerCompilerForRPCs: RPCHandler 繫結 compiler

通過呼叫 plugin 初始化時生成的 handler 上的方法,完成對 compiler 物件的呼叫繫結。

2. normalizeLoaders: loader 解析

對應中的 webpack 中的 happypackPlugin 的 loaders 配置的處理:

對應配置的 loaders ,經過 normalizeLoader 的處理後,例如 [css!less] 會返回成一個loader 陣列 [{path: 'css'},{path: 'less'}],複製到 plugin 的 this.state 屬性上。

3.resolveLoaders: loader 對應檔案路徑查詢

為了實際執行 loader 過程,這裡將上一步 loader 解析 處理過後的 loaders 陣列傳遞到resolveLoaders 方法中,進行解析

resolveLoaders 方法採用的是借用原有 webpack 的 compiler 物件上的對應resolvers.loader 這個 Resolver 例項的 resolve 方法進行解析,構造好解析引數後,通過async.parallel 並行解析 loader 的路徑

4.loadCache: cache 載入

cache 載入通過呼叫 cache.load 方法來載入上一次構建的快取,快速提高構建速度。

load 方法會去讀取 cachePath 這個路徑的快取配置檔案,然後將內容設定到當前 cache物件上的 mtimes 上。

在 happypack 設計的構建快取中,存在一個上述的一個快取對映檔案,裡面的配置會對映到一份編譯生成的快取檔案。

5.launchAndConfigureThreads: 執行緒池啟動

上面有提到,在載入完 HappyPlugin 時,會建立對應的 HappyThreadPool 物件以及設定數量的 HappyThread。但實際上一直沒有建立真正的子程式例項,這裡通過呼叫threadPool.start 來進行子程式建立。

start 方法通過 sendnotget 這三個方法來進行過濾、啟動的串聯。

傳遞 'isOpen'send 返回函式中,receiver 物件繫結呼叫 isOpen 方法;再傳遞給 not返回函式中,返回前面函式結構取反。傳遞給 threadsfilter 方法進行篩選;最後通過 get 傳遞返回的 open 屬性。

HappyThread 物件中 isOpen 通過判斷 fd 變數來判斷是否建立子程式。

HappyThread 物件的 open 方法首先將 async.parallel 傳遞過來的 callback 鉤子通過Once 方法封裝,避免多次觸發,返回成 emitReady 函式。

然後呼叫 childProcess.fork 傳遞 HappyWorkerChannel.js 作為子程式執行檔案來建立一個子程式,繫結對應的 errorexit 異常情況的處理,同時繫結最為重要的 message 事件,來接受子程式發來的處理訊息。而這裡 COMPILED 訊息就是對應的子程式完成編譯之後會發出的訊息。

在子程式完成建立之後,會向主程式傳送一個 READY 訊息,表明已經完成建立,在主程式接受到 READY 訊息後,會呼叫前面封裝的 emitReady ,來反饋給 async.parallel 表示完成open 流程。

6.markStarted: 標記啟動

最後一步,在完成之前的步驟後,修改狀態屬性 startedtrue,完成整個外掛的啟動過程。

編譯執行

tb1qd5uofxxxxxqxxxxxxxxxxxx-1141-720

1. loader 傳遞
在 webpack 流程中,在原始碼檔案完成內容讀取之後,開始進入到 loader 的編譯執行階段,這時 HappyLoader 作為編譯邏輯入口,開始進行編譯流程。

loader 中將 webpack 原本的 loaderContext(this指向) 物件的一些引數例如this.resourcethis.resourcePath等透傳到 HappyPlugin.compile 方法進行編譯。

2. plugin 編譯邏輯執行

HappyPlugin 中的 compile 方法對應 build 過程,通過呼叫 compileInBackground 方法來完成呼叫。

2.1 構建快取判斷

compileInBackground 中,首先會代用 cache 的 hasChangedhasErrored 方法來判斷是否可以從快取中讀取構建檔案。

hasError 判斷的是更新快取的時候的 error 屬性是否存在。

hasChanged 中會去比較 nowMTimelastMTime 兩個是否相等。實際上這裡 nowMTime 通過呼叫 generateSignature(預設是 getMTime 函式) 返回的是檔案目前的最後修改時間,lastMTime 返回的是編譯完成時的修改時間。

如果 nowMTimelastMTime 兩個的最後修改時間相同且不存在錯誤,那麼說明構建可以利用快取

2.1.1 快取生效

如果快取判斷生效,那麼開始呼叫 readFromCache 方法,從快取中讀取構建對應檔案內容。

函式的意圖是通過 cache 物件的 getCompiledSourceCodePathgetCompiledSourceMapPath方法獲取快取的編譯檔案及 sourcemap 檔案的儲存路徑,然後讀取出來,完成從快取中獲取構建內容。

獲取的路徑是通過在完成編譯時呼叫的 updateMTimeFor 進行儲存的物件中的 compiledPath編譯路徑屬性。

2.1.2 快取失效

在快取判斷失效的情況下,進入 _performCompilationRequest ,進行下一步 happypack 編譯流程。

在呼叫 _performCompilationRequest 前, 還有一步是從 ThreadPool 獲取對應的子程式封裝物件。

這裡按照遞增返回的 round-robin,這種在伺服器程式控制中經常使用的簡潔演算法返回子程式封裝物件。

3. 編譯開始

首先對編譯的檔案,呼叫 cache.invalidateEntryFor 設定該檔案路徑的構建快取失效。然後呼叫子程式封裝物件的 compile 方法,觸發子程式進行編譯。

同時會生成銜接主程式、子程式、快取的 compiledPath,當子程式完成編譯後,會將編譯後的程式碼寫入 compiledPath,之後傳送完成編譯的訊息回主程式,主程式也是通過compiledPath 獲取構建後的程式碼,同時傳遞 compiledPath 以及對應的編譯前檔案路徑filePath,更新快取設定。

這裡的 messageId 是個從 0 開始的遞增數字,完成回撥方法的儲存註冊,方便完成編譯之後找到回撥方法傳遞資訊回主程式。同時在 thread 這一層,也是將引數透傳給子程式執行編譯。

 

子程式接到訊息後,呼叫 worker.compile 方法 ,同時進一步傳遞構建引數。

在 HappyWorker.js 中的 compile 方法中,呼叫 applyLoaders 進行 loader 方法執行。applyLoadershappypack 中對 webpack 中 loader 執行過程進行模擬,對應 NormalModuleMixin.js 中的 doBuild 方法。完成對檔案的字串處理編譯。

根據 err 判斷是否成功。如果判斷成功,則將對應檔案的編譯後內容寫入之前傳遞進來的compiledPath,反之,則會把錯誤內容寫入。

在子程式完成編譯流程後,會呼叫傳遞進來的回撥方法,在回撥方法中將編譯資訊返回到主程式,主程式根據 compiledPath 來獲取子程式的編譯內容。

獲取子程式的編譯內容 contents 後,根據 result.success 屬性來判斷是否編譯成功,如果失敗的話,會將 contents 作為錯誤傳遞進去。

在完成呼叫 updateMTimeFor 快取更新後,最後將內容返回到 HappyLoader.js 中的回撥中,返回到 webpack 的原本流程。

4. 編譯結束

當 webpack 整體編譯流程結束後, happypack 開始進行一些善後工作

4.1. 儲存快取配置

首先呼叫 cache.save() 儲存下這個快取的對映設定。

cache 物件的處理是會將這個檔案直接寫入 cachePath ,這樣就能供下一次 cache.load 方法裝載配置,利用快取。

4.2. 終止子程式

其次呼叫 threadPool.stop 來終止掉程式

類似前面提到的 start 方法,這裡是篩選出來正在執行的 HappyThread 物件,呼叫 close方法。

HappyThread 中,則是呼叫 kill 方法,完成子程式的釋放。

彙總

happypack 的處理思路是將原有的 webpack 對 loader 的執行過程從單一程式的形式擴充套件多程式模式,原本的流程保持不變。整個流程程式碼結構上還是比較清晰,在使用過程中,也確實有明顯提升,有興趣的同學可以一起下來交流~