從 Turborepo 看 Monorepo 工具的任務編排能力

海秋發表於2022-02-17
本文大部分圖片來自網際網路

monorepo

前言

2021 年 12 月 9 號,Vercel 的官方部落格上釋出了一篇名為 Vercel acquires Turborepo to accelerate build speed and improve developer experience 的博文,正如其標題所說,Vercel 收購了 Turborepo,以加速構建速度以及提高開發體驗。

Vercel+TURBONREPO

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 存在的意義。

列舉筆者遇到的一些實際場景:

  1. 依賴版本衝突

    1. 新建一個專案,該專案由於依賴問題無法啟動
    2. 新建一個專案,其他專案由於依賴問題無法啟動
  2. 依賴安裝速度慢

    1. 初始化安裝依賴 20min+
    2. 新增一個依賴 3min+
  3. build/test/lint 等任務執行慢

筆者先前有過 Rush 的落地經驗,在實踐過程中,發現除了最基本的程式碼共享能力外,還應當至少具備三種能力,即:

  1. 依賴管理能力。隨著依賴數量的增加,依舊能夠保持依賴結構的正確性、穩定性以及安裝效率。
  2. 任務編排能力。能夠以最大的效率以及正確的順序執行 Monorepo 內專案的任務(可以狹義理解為 npm scripts,如 build、test 以及 lint 等),且複雜度不會隨著 Monorepo 內專案增多而增加。
  3. 版本釋出能力。能夠基於改動的專案,結合專案依賴關係,正確地進行版本號變更、CHANGELOG 生成以及專案釋出。

一些流行工具的支援能力如下表所示:

-依賴管理任務編排版本管理
Pnpm Workspace
Rush✅(by Pnpm)
Lage
Turborepo
Lerna
  1. Pnpm:Pnpm 具備一定的任務編排能力 (--filter 引數),故此處也將其列入,同時作為 Package Manager,其自身更是大型 Monorepo 不可或缺的一部分。
  2. Rush:由微軟開源的可擴充套件 Monorepo 管理方案,內建 PNPM 以及類 Changesets 發包方案,其外掛機制是一大亮點,使得利用 Rush 內建能力實現自定義功能變得極為方便,邁出了 Rush 外掛生態圈的第一步。
  3. Lage :同樣由微軟開源,個人認為是 Turborepo 的前身,Turborepo 是 Lage 的 Go 語言版本。Lage 自稱為 "Monorepo Task Runner",相較於 Turborepo 的 "High-Performance Build System" 內斂許多,Star 數也相差了一個數量級(Lage 300+,而 Turborepo 5k+),更多可檢視該 PR。在後文中 Lage 等同於 Turborepo。
  4. Lerna:已經停止維護,故後續討論不會將其納入。

依賴管理過於底層,版本控制較為簡單且已成熟,將這兩項能力再做突破是比較困難的,實踐中基本都是結合 Pnpm 以及 Changesets 補全整體能力,甚至就乾脆專精於一點,即任務編排,也就是 Lage 以及 Turborepo 的發力點。

Changesets

如何選擇合適自己的 Monorepo 工具鏈?

  1. Pnpm Workspace + Changesets:成本低,滿足大多數場景
  2. Pnpm Workspace + Changesets + Turborepo/Lage:在 1 的基礎上增強任務編排能力
  3. Rush:考慮全面,擴充套件性強

任務編排可以劃分為三個步驟,各工具支援如下:

範圍界定並行執行雲端快取
Pnpm
Rush
Turborepo/Lage

範圍界定:按需執行子集任務

Filtering/Scoping/Selecting subsets of projects

該能力在日常開發中具有豐富的使用場景。

例如第一次拉取倉庫,啟動專案 app1 需要構建 Monorepo 內 app1 的前置依賴 package1 以及 package2。

而在 SCM 上打包專案 app1 時,需要構建 app1 自身以及 Monorepo 內 app1 的前置依賴 package1 以及 package2。

此時則應該根據需要篩選出需要構建的專案,而不應該引入與當前意圖無關的專案構建。

在不同的 Monorepo 工具中,這一行為有著不同的稱呼:

  1. Rush 中稱之為 Selecting subsets of projects,選擇專案子集,在本示例中應當使用如下命令:
// 本地啟動 app1 開發模式,app1 為依賴圖的頂端,但不需要構建 app1 自身
$ rush build --to-except @monorepo/app1

// SCM 打包 app1,app1 為依賴圖的頂端,且需要構建 @monorepo/app1 自身
$ rush build --to @monorepo/app1
  1. Pnpm 中稱之為 Filtering,即過濾,將命令限制於包的特定子集,在本示例中應當使用如下命令:
// 本地啟動 app1 開發模式,app1 為依賴圖的頂端,但不需要構建 app1 自身
$ pnpm build --filter @monorepo/app1^...

// SCM 打包 app1,app1 為依賴圖的頂端,且需要構建 @monorepo/app1 自身
$ pnpm build --filter @monorepo/app1...
  1. 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,配合開發者傳入的引數,從而方便地進行子集專案選擇。

並行執行:充分釋放機器效能

Local task orchestration

假設挑選出了 20 個子集任務,應該如何執行這 20 個任務來保證正確性以及效率呢?

Project 之間存在依賴關係,那麼任務之間也存在依賴關係,以 build 任務為例,只有前置依賴構建完畢,才可構建當前專案。

網上有一道比較流行的控制最大併發數面試題,大致題意是:給定 m 個 url,每次最大並行請求數為 n,請實現程式碼保證最大請求數。

max-request-count

這道題的思路其實與任務編排中的任務並行執行大同小異,只不過面試題中的 url 不存在依賴關係,而任務之間存在拓撲序,差別僅此而已。

那麼任務的執行思路也就呼之欲出了:

  1. 初始可執行的任務一定是不存在任何前置任務的任務

    • 其 Dependencies 數量為 0
  2. 一個任務執行完成後,從任務佇列中查詢下一個可執行的任務,並立刻執行

    • 一個任務執行完成後,需要更新其 Dependents 的 Dependencies 數量,從其內移除當前任務(Dependencies 數量 -1)
    • 一個任務是否可執行,取決於其 Dependencies 是否全部執行完畢(Dependencies 數量為 0)

本文不作程式碼層面講解,具體實現可見 Monorepo 中的任務排程機制 一文,在程式碼層面上實現了任務的拓撲序並行執行。

打破任務邊界

turborepo-lerna

本圖來自 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": {}
  }
}

正確編排順序

fix-turbo-pipeline

Rush 在 20 年 3 月以及 10 月也進行過相關設計的討論,並於 21 年年底支援了類似的功能特性,具體 PR 可查閱 [[rush] Add support for phased commands. #3113](https://github.com/microsoft/...)

雲端快取:跨多環境複用快取

Distributed computation caching

Rush 具備增量構建的特性,使 rush build 能夠跳過自上次構建以來輸入檔案(input files)沒有變化的專案,配合第三方儲存服務,可以達到跨多環境複用快取的效果。

Rush 在 5.57.0 版本引入了外掛機制 ,進而支援了第三方遠端快取能力(在此之前僅支援 azure 與 amazon),賦予了開發者實現基於企業內部服務的構建快取方案的能力。

落地到日常開發場景中,本地開發、CI 以及 SCM 各開發環節都能從中受益。

上文有提到,在 CI 環節構建改動專案及其上下游專案可以一定程度上保證 Merge Request 的質量。

Build changed projects 1

如上圖所示,存在場景修改了 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 十分緩慢。

實際構建專案數 = 改動專案的下游專案數 + 改動專案的上游專案數 + 改動專案的下游專案前置依賴數+ 改動專案上游專案的前置依賴數

Build changed projects 2

由於 package1-package5 等 5 個專案與 package0 不存在直接或間接的依賴關係,且輸入檔案沒有改變,故能夠命中快取(如有),跳過構建行為。

如此便將構建範圍由 7 個 project 降至 2 個 project。

實際構建專案數 = 改動專案的下游專案數 + 改動專案的上游專案數

如何判斷是否命中快取?

Detecting affected projects/packages

在雲端,每一個專案構建結果的快取壓縮包與其輸入檔案 input files 計算出來的 cacheId 形成對映,輸入檔案未發生變化,則計算出來的 cacheId 值就不會變化(內容雜湊),就能命中對應的雲端快取。

輸入檔案包含以下內容:

  1. 專案程式碼原始檔
  2. 專案 NPM 依賴
  3. 專案依賴的其他 Monorepo 內部專案的 cacheId

若對實現感興趣,可以檢視 @rushstack/package-deps-hash

結語

在編寫本文過程中筆者也想起了 @sorrycc 在 GMTC 上分享的 《前端構建提速的體系化思路》中提到的構建提速三大法寶:

  1. 延遲處理。基於請求的按需編譯、延遲編譯 sourcemap
  2. 快取。Vite Optmize、Webpack5 物理快取、Babel 快取
  3. 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。

參考

相關文章