本文大部分圖片來自網際網路
前言
2021 年 12 月 9 號,Vercel 的官方部落格上釋出了一篇名為 Vercel acquires Turborepo to accelerate build speed and improve developer experience 的博文,正如其標題所說,Vercel 收購了 Turborepo,以加速構建速度以及提高開發體驗。
Turborepo 是一個用於 JavaScript 和 TypeScript 程式碼庫的高效能構建系統。通過增量構建、智慧遠端快取和優化的任務排程,Turborepo 可以將構建速度提高 85% 或更多,使各種規模的團隊都能夠維護一個快速有效的構建系統,該系統可以隨著程式碼庫和團隊的成長而擴充套件。
博文中已經簡明扼要的突出了 Turborepo 的優勢,本文則會從現有的實際場景出發,談談大型程式碼倉庫(Monorepo)可能會遇到的一些問題,再結合業界現有的解決方案,看看 Turborepo 在任務編排方面做出了哪些創新與突破。
一個合格 Monorepo 的自我修養
隨著業務的發展和團隊的變化,業務型 Monorepo 中的專案會逐漸增加,極端一點的例子就是 Google 將整個公司的程式碼都放到一個倉庫中,倉庫的大小達到了 80TB。
業務型 Monorepo:不同於 lib 型 Monorepo(React、Vue3、Next.js 以及 Babel 等廣義上的 packages),業務型 Monorepo 將多個業務應用 App 及其依賴的公用元件庫或工具庫組織到了一個倉庫中。 ——《Eden Monorepo 系列:淺析 Eden Monorepo 工程化建設》
專案數量的增加意味著在享受 Monorepo 優勢的同時,也帶來了巨大的挑戰,優秀的 Monorepo 工具可以讓開發者毫無負擔的享受 Monorepo 的優勢,而不好用的 Monorepo 工具可以讓開發者痛不欲生,甚至讓人懷疑 Monorepo 存在的意義。
列舉筆者遇到的一些實際場景:
依賴版本衝突
- 新建一個專案,該專案由於依賴問題無法啟動
- 新建一個專案,其他專案由於依賴問題無法啟動
依賴安裝速度慢
- 初始化安裝依賴 20min+
- 新增一個依賴 3min+
- build/test/lint 等任務執行慢
筆者先前有過 Rush 的落地經驗,在實踐過程中,發現除了最基本的程式碼共享能力外,還應當至少具備三種能力,即:
- 依賴管理能力。隨著依賴數量的增加,依舊能夠保持依賴結構的正確性、穩定性以及安裝效率。
- 任務編排能力。能夠以最大的效率以及正確的順序執行 Monorepo 內專案的任務(可以狹義理解為 npm scripts,如 build、test 以及 lint 等),且複雜度不會隨著 Monorepo 內專案增多而增加。
- 版本釋出能力。能夠基於改動的專案,結合專案依賴關係,正確地進行版本號變更、CHANGELOG 生成以及專案釋出。
一些流行工具的支援能力如下表所示:
- | 依賴管理 | 任務編排 | 版本管理 |
---|---|---|---|
Pnpm Workspace | ✅ | ✅ | ❌ |
Rush | ✅(by Pnpm) | ✅ | ✅ |
Lage | ❌ | ✅ | ❌ |
Turborepo | ❌ | ✅ | ❌ |
Lerna | ❌ | ✅ | ✅ |
- Pnpm:Pnpm 具備一定的任務編排能力 (
--filter
引數),故此處也將其列入,同時作為 Package Manager,其自身更是大型 Monorepo 不可或缺的一部分。 - Rush:由微軟開源的可擴充套件 Monorepo 管理方案,內建 PNPM 以及類 Changesets 發包方案,其外掛機制是一大亮點,使得利用 Rush 內建能力實現自定義功能變得極為方便,邁出了 Rush 外掛生態圈的第一步。
- Lage :同樣由微軟開源,個人認為是 Turborepo 的前身,Turborepo 是 Lage 的 Go 語言版本。Lage 自稱為 "Monorepo Task Runner",相較於 Turborepo 的 "High-Performance Build System" 內斂許多,Star 數也相差了一個數量級(Lage 300+,而 Turborepo 5k+),更多可檢視該 PR。在後文中 Lage 等同於 Turborepo。
- Lerna:已經停止維護,故後續討論不會將其納入。
依賴管理過於底層,版本控制較為簡單且已成熟,將這兩項能力再做突破是比較困難的,實踐中基本都是結合 Pnpm 以及 Changesets 補全整體能力,甚至就乾脆專精於一點,即任務編排,也就是 Lage 以及 Turborepo 的發力點。
如何選擇合適自己的 Monorepo 工具鏈?
- Pnpm Workspace + Changesets:成本低,滿足大多數場景
- Pnpm Workspace + Changesets + Turborepo/Lage:在 1 的基礎上增強任務編排能力
- Rush:考慮全面,擴充套件性強
任務編排可以劃分為三個步驟,各工具支援如下:
範圍界定 | 並行執行 | 雲端快取 | |
---|---|---|---|
Pnpm | ✅ | ✅ | ❌ |
Rush | ✅ | ✅ | ✅ |
Turborepo/Lage | ✅ | ✅ | ✅ |
範圍界定:按需執行子集任務
該能力在日常開發中具有豐富的使用場景。
例如第一次拉取倉庫,啟動專案 app1 需要構建 Monorepo 內 app1 的前置依賴 package1 以及 package2。
而在 SCM 上打包專案 app1 時,需要構建 app1 自身以及 Monorepo 內 app1 的前置依賴 package1 以及 package2。
此時則應該根據需要篩選出需要構建的專案,而不應該引入與當前意圖無關的專案構建。
在不同的 Monorepo 工具中,這一行為有著不同的稱呼:
- Rush 中稱之為 Selecting subsets of projects,選擇專案子集,在本示例中應當使用如下命令:
// 本地啟動 app1 開發模式,app1 為依賴圖的頂端,但不需要構建 app1 自身
$ rush build --to-except @monorepo/app1
// SCM 打包 app1,app1 為依賴圖的頂端,且需要構建 @monorepo/app1 自身
$ rush build --to @monorepo/app1
- Pnpm 中稱之為 Filtering,即過濾,將命令限制於包的特定子集,在本示例中應當使用如下命令:
// 本地啟動 app1 開發模式,app1 為依賴圖的頂端,但不需要構建 app1 自身
$ pnpm build --filter @monorepo/app1^...
// SCM 打包 app1,app1 為依賴圖的頂端,且需要構建 @monorepo/app1 自身
$ pnpm build --filter @monorepo/app1...
- Turborepo/Lage 中稱之為Scoped Tasks,但目前(2022/02/13)這一能力過於侷限,Vercel 團隊正在設計一套與 Pnpm 基本一致的 filter 語法,詳情參見 RFC: New Task Filtering Syntax
範圍界定保證了執行任務的數量不會隨著 Monorepo 內無關專案的增加而增加,豐富的引數能夠幫助我們在各種場景(package 發包、app 構建以及 CI 任務)去進行 selecting/filtering/scoping。
比如修改了 package5,在 Merge Request 的 CI 環境需要保證 package5 以及依賴 package5 的專案不會因為本次修改而構建失敗,則可以使用以下命令:
// 使用 Rush
$ rush build --to @monorepo/package5 --from @monorepo/package5
// 使用 Pnpm
$ pnpm build --filter ...@monorepo/package5...
在本示例中最終會挑選出 package5 以及 app3 進行構建,從而在 CI 上達到了合入程式碼的最低要求——不影響其他專案構建。
基於工作區所有專案的 package.json 檔案,可以方便地得到專案之間的具體依賴關係,每一個專案 Project 都知曉其上游專案 Dependents 以及其下游依賴 Dependencies,配合開發者傳入的引數,從而方便地進行子集專案選擇。
並行執行:充分釋放機器效能
假設挑選出了 20 個子集任務,應該如何執行這 20 個任務來保證正確性以及效率呢?
Project 之間存在依賴關係,那麼任務之間也存在依賴關係,以 build 任務為例,只有前置依賴構建完畢,才可構建當前專案。
網上有一道比較流行的控制最大併發數面試題,大致題意是:給定 m 個 url,每次最大並行請求數為 n,請實現程式碼保證最大請求數。
這道題的思路其實與任務編排中的任務並行執行大同小異,只不過面試題中的 url 不存在依賴關係,而任務之間存在拓撲序,差別僅此而已。
那麼任務的執行思路也就呼之欲出了:
初始可執行的任務一定是不存在任何前置任務的任務
- 其 Dependencies 數量為 0
一個任務執行完成後,從任務佇列中查詢下一個可執行的任務,並立刻執行
- 一個任務執行完成後,需要更新其 Dependents 的 Dependencies 數量,從其內移除當前任務(Dependencies 數量 -1)
- 一個任務是否可執行,取決於其 Dependencies 是否全部執行完畢(Dependencies 數量為 0)
本文不作程式碼層面講解,具體實現可見 Monorepo 中的任務排程機制 一文,在程式碼層面上實現了任務的拓撲序並行執行。
打破任務邊界
本圖來自 Turborepo: Pipelining Package Tasks
之前談到任務執行時,都是在同一種任務下,比如 build、lint 或是 test,在並行執行 build 任務時,不會去考慮 lint 或是 test 任務。如上圖 Lerna 區域所示,依次執行四種任務,每一種任務都被前一種任務阻塞住了,即使內部是並行執行的,但不同任務之間依舊存在了資源浪費。
Lage/Turborepo 為開發者提供了一套明確任務關係的方法(見 turbo.json),基於該關係,Lage/Turborepo 可以去進行不同種類任務間的排程和優化。
相較於一次只能執行一種任務,重疊瀑布式的任務執行效率當然要高得多。
turbo.json
{
"$schema": "https://turborepo.org/schema.json",
"pipeline": {
"build": {
// 其依賴項構建命令完成後,進行構建
"dependsOn": ["^build"]
},
"test": {
// 自身的構建命令完成後,進行測試(故上圖存在錯誤)
"dependsOn": ["build"]
},
"deploy": {
// 自身 lint 構建測試命令完成後,進行部署
"dependsOn": ["build", "test", "lint"]
},
// 隨時可以開始 lint
"lint": {}
}
}
正確編排順序
Rush 在 20 年 3 月以及 10 月也進行過相關設計的討論,並於 21 年年底支援了類似的功能特性,具體 PR 可查閱 [[rush] Add support for phased commands. #3113](https://github.com/microsoft/...)
- Turborepo: Pipelining Package Tasks
- How does lage work?
- [[rush] Design proposal: "phased" custom commands #2300](https://github.com/microsoft/...)
雲端快取:跨多環境複用快取
Rush 具備增量構建的特性,使 rush build 能夠跳過自上次構建以來輸入檔案(input files)沒有變化的專案,配合第三方儲存服務,可以達到跨多環境複用快取的效果。
Rush 在 5.57.0 版本引入了外掛機制 ,進而支援了第三方遠端快取能力(在此之前僅支援 azure 與 amazon),賦予了開發者實現基於企業內部服務的構建快取方案的能力。
落地到日常開發場景中,本地開發、CI 以及 SCM 各開發環節都能從中受益。
上文有提到,在 CI 環節構建改動專案及其上下游專案可以一定程度上保證 Merge Request 的質量。
如上圖所示,存在場景修改了 package0 的程式碼,為了保證其上下游構建不被影響,則在 CI Build Changed Projects 階段,會執行以下命令:
$ rush build --to package0 --from package0
基於 git diff 挑選出原始檔改動的 projects,此處為 package0
經過範圍界定,package0 及其上游 app1 會被納入構建流程,由於 app1 需要構建,作為其前置依賴,package1 至 package5 也需要被構建,但這 5 個 package 實際上與 package0 並不存在依賴關係,也不存在變更,僅為了完成 app1 的構建準備工作。
若依賴關係複雜起來,比如某個基礎包被多個應用引用,那麼類似於 package1-package5 的準備構建工作就會大大增多,導致這一階段 CI 十分緩慢。
實際構建專案數 = 改動專案的下游專案數 + 改動專案的上游專案數 + 改動專案的下游專案前置依賴數+ 改動專案上游專案的前置依賴數
由於 package1-package5 等 5 個專案與 package0 不存在直接或間接的依賴關係,且輸入檔案沒有改變,故能夠命中快取(如有),跳過構建行為。
如此便將構建範圍由 7 個 project 降至 2 個 project。
實際構建專案數 = 改動專案 + 改動專案的上游專案數
如何判斷是否命中快取?
在雲端,每一個專案構建結果的快取壓縮包與其輸入檔案 input files 計算出來的 cacheId 形成對映,輸入檔案未發生變化,則計算出來的 cacheId 值就不會變化(內容雜湊),就能命中對應的雲端快取。
輸入檔案包含以下內容:
- 專案程式碼原始檔
- 專案 NPM 依賴
- 專案依賴的其他 Monorepo 內部專案的 cacheId
若對實現感興趣,可以檢視 @rushstack/package-deps-hash。
結語
在編寫本文過程中筆者也想起了 @sorrycc 在 GMTC 上分享的 《前端構建提速的體系化思路》中提到的構建提速三大法寶:
- 延遲處理。基於請求的按需編譯、延遲編譯 sourcemap
- 快取。Vite Optmize、Webpack5 物理快取、Babel 快取
- Native Code。SWC、ESBuild
作為任務編排工具來講,Native Code 的優勢並不明顯(雖然 Turborepo 使用 Go 語言編寫,但 Lage 作者認為在現有規模下,任務編排的效率瓶頸並不在編排工具本身),但延遲處理與快取是有異曲同工之妙的。
最後使用精簡且務實的 Lage 官網副標題作為本文主題「任務編排」的結尾:
Run all your npm scripts in topological order incrementally with cloud cache - @microsoft/lage
配合雲端快取,依照拓撲排序增量執行你所有的 npm scripts。