Nx vs Turborepo,怎麼在大型 Monorepo 優化開發體驗?
隨著前端的程式碼規模在 Dcard 越來越大,其中一個遇到的狀況就是,CI 的執行速度、local 的開發體驗都開始受到影響。
考慮到 monorepo 整體還是利大於弊,而且我們很重視迭代速度,於是決定投入更多來改善這個環節。剛好去年年底 Turborepo 的發佈吸引了我們的目光,就決定來好好分析一下該怎麼改善我們現行的架構與流程。
我們的 Monorepo
為了方便大家能大概了解我們前端這個 monorepo 目前的規模,先介紹一下我們的目錄架構:
packages/
sites/
package.json
yarn.lock
我們的 website 放在 sites/
裡面,大部分是使用 Next.js 建構,而共用 package 則是放在 packages/
裡面,目前大約有 80+ 個 package 以及 20+ 個 site。
一個全新 clone 下來的 repo,統計下來是約有九千多個檔案:
$ find . -type f | wc -l
9029
方法演進
在我們公司裡,前端最早也是擁有非常多個 repo,在搬往 monorepo 時曾經考慮過 Lerna,不過後來則選用 Yarn Berry (v2/v3) 搭配一些自己實作的 Yarn Plugins。
其中 yarn changed plugin 可以利用 Git 以及 workspace dependencies 計算出有被 commit 改動的 workspace:
雖然這樣一個簡潔的 solution 已經帶來很不錯的效果了,但仔細比較還是缺少了許多在大型 monorepo 會需要的功能,例如:
- 在偵測改動時缺乏彈性,不能指定每個 workspace 的細節設定,像是隱含的相依性
- 沒有 task orchestrator 幫忙安排 task 的最佳執行順序
- 重複執行的 task 沒有 local computation cache 可以用
大部分的 monorepo tools,包括 Google 的 Bazel、Facebook 的 Buck,幾乎都來自同一個源頭,運作原理相當類似。
因為我們這個 monorepo 主要是由 TypeScript/JavaScript 組成,沒有需要涵蓋到 Golang/iOS/Android 的 repo,還不需要一些更複雜的設定,所以我們主要從 Turborepo 跟 Nx 之中來考慮。
Turborepo vs Nx
Turborepo 是由蠻有名氣的 Jared Palmer 開始開發,去年年底被 Vercel 收購,並因此竄紅的工具。Nx 則是由 ex-Googler 出來打造的工具,參考許多 Google 內部系統的概念,已經有接近 5 年的歷史。
我自己是 Vercel 的信徒,但在做技術選擇時還是不能偏頗,在前不久試用 Turborepo 時,雖然設定簡潔而且也有達到基本的功能,但在與 Nx 相比之下還是能明顯感覺到兩者的優劣。
即便 Turborepo 是用 Go 撰寫,Nx 是使用 TypeScript 撰寫,但在大部分情況下,包括 cache 完全命中的情境,Nx 的速度都略勝 Turborepo,詳細的benchmark 可參考這個 repo,我們自己實測的時候也是有觀察到類似的結果。
再來一個是我覺得最致命的地方,Turborepo 會在 terminal 輸出加上所屬 task 的前綴,從下面這張圖就可以大致看得出來。更尷尬的是,當平行在跑多個 task 的時候,task 的 terminal 輸出會交錯出現幾乎不可讀,且輸出很長也不會收合。
以相依性分析來比較,Nx Graph 能產生一個互動式介面,讓你搜尋、過濾、查看你想了解的相依性部分,對於新人進入專案非常有幫助。而在這個功能上,Turborepo 目前只能用指令產生一張 Graphviz DAG 圖檔,在我們的規模下,100 個節點跟數不清的線會靠在一起幾乎不可辨識。
再來就是設定的細緻程度,Turborepo 才出不久,很多東西是缺乏彈性的,相比之下 Nx 打磨了快五年,彈性跟擴充性的差距非常明顯。Nx 更是有一些 monorepo 領域的專業功能,例如:Distributed Task Execution(DTE)。
除此之外,可靠性也是其中一環,作為一個還很新的工具,Turborepo 並不如 Nx 穩定。因此我們目前決定使用 Nx,希望 Turborepo 某天能迎頭趕上。
分項改進
除了套用 Nx 外,要讓 CI 的執行速度、local 的開發體驗能夠最佳化, 還必須仔細檢討 checkout / install / build / lint / test 等每一個必要的環節是否足夠有效率,這邊會稍微介紹一些我們做過哪些研究跟處理。
Checkout
在 CI 上,通常會預設有一些對應的優化,例如 GitHub Actions 跟 Travis CI 都有對 git clone / git fetch 的 depth 設定比較小的預設值,可以避免完整的 history 太過龐大。
在 Circle CI 的 checkout 腳本中也看得出來,可以透過 persist project/.git
的方式來優化,跳過 clone 的步驟直接進入 fetch & checkout。
在規模更大的 monorepo 中,可能會需要用到 git sparse-checkout 或是其他的機制來避免一次 checkout 太多的檔案。
Install
我們目前是使用 Yarn Berry (v2/v3) 搭配 node_modules
linker,在 CircleCI 上 cache .yarn
不 cache node_modules
可以達到最快的速度。
因為 yarn 只有一個 lock file,在 build 每個 workspace 時都要安裝所有套件很沒效率,所以我們用了 yarn workspaces focus
指令,在 build 一些比較小的 workspace 時安裝時間直接縮短了一半以上。
不過現在的 yarn 還是不夠彈性,如果未來需要用到 git sparse-checkout
就會有很多延伸問題,所以我們也在關注未來有沒有產生多個 lock file 的方法。
Build
有鑒於 TypeScript 跟 Next.js 相關的程式碼會越來越龐大,重 build 所有程式碼非常的沒有效率,所以這個步驟也需要善用 incremental build。
TypeScript 有 incremental 的 config 可以設定,會產生 .tsbuildinfo
讓下一次 build 的時候可以使用。
Next.js 則一樣可以留下 .next/cache
讓下一次的 build 可以使用。
利用 Nx 也能只 build 程式碼或是 dependencies 有改變的 workspace,其餘則直接利用 cache 的結果。
我們最近也開始把部分 Next.js site 的 Babel 換成 SWC,可以參考「採用 SWC 取代 Babel,大幅提升編譯速度」一文。
Lint
在這個環節我們主要的工具是 ESLint、StyleLint 跟 Prettier,在 local 開發時我們已經很習慣使用 lint-staged 來執行,只會跑到有修改的檔案。
在這次的優化中,我們在 CI 上利用 git diff --name-only --diff-filter=ACMRTUXB <BASE>
來找出 PR 所修改的檔案,並針對這些檔案去跑 lint。這樣做有很多很好的理由:
- 如果有的程式已經穩穩的跑了好幾年,而且沒有修改的必要,沒有很好的理由去強制變動它
- PR 沒改到的 file 如果 lint 不過,不應該是這個 PR 的責任,不應該導致 PR lint fail
- 對大量沒改動的程式碼跑 lint 會耗時數分鐘,在急速增長的 codebase 下,越來越不合理
Test
我們的測試主要是靠 Jest 跟 Cypress 來執行,利用 Nx 也能靠 nx affected:test
和 nx affected:e2e
來在 PR 只跑相關的測試。
除此之外,Nx 提供的 Jest executor 裡面有個很有趣的 batch 實作,可以利用 Jest CLI 一次跑多個 project 的功能來做執行上的優化,目前這個 NX_BATCH_MODE
看似還有不少問題,不過也值得繼續研究。
進階技巧
Binning vs DTE(Distributed Task Execution)
如果 CI 必須執行的 task 很多、執行時間也不短,為了節省開發者等待的時間,最好是能同時執行多個 task。
最簡單的方法當然就是直接拆分工作量,例如手動拆分或是透過計算自動化拆分:
透過 nx print-affected
可以得到被影響到需要重跑的 task,然後就可以按照這個清單去分配。
不過這樣做也有顯而易見的缺點,例如:相依性複雜的 workspace 無法分開 build、統整測試涵蓋率報告的流程複雜。
另一個在大型 monorepo 常見的策略是 DTE,分散式的執行 task,其訣竅在於需要有一個角色負責協調,把子任務分別派發去給數個 agent 去做,並收回產生的檔案跟 terminal 輸出:
在 Nx 中把 default runner 換成 Nx Cloud 的 runner 是實現這個功能的最快方法,Nx 本身的 CI config 就體現了這個編排。其實不只是在 CI 上,這個機制也能利用資源協助開發機跑平常開發中會跑的各種指令。
Nx 的創辦人寫的這篇「Distributing CI: Binning and Distributed Task Execution」,很好的解釋了兩者的優缺點,很建議看看。
Distributed Computation Caching(Remote Cache)
一般情況下使用的 local computation caching 是讓團隊每個人的 local 以及 CI 上擁有自己獨立的 cache,用來避免重複的計算。
而 distributed computation caching 的設計則是讓團隊每個人的 local 以及 CI 都共用同一份 remote cache,可以最大程度地避免重複的計算。
當然這也不是一個百分之百有效的策略,如果你的 cache 很大、網速很慢,task 卻執行得非常快,那有可能反而帶來反效果。
特別感謝同事 Evan、Tommy 幫忙校稿、以及 EJ 幫忙繪制精美的封面圖!
另外工商一下,Dcard 正在徵才中!我們正在尋找 Web Frontend Developer 夥伴加入我們。團隊常遇到 Performance (Web Vitals)、SSR 與 i18n 相關挑戰,以及如何以良好的架構去解決實際問題。最終目的圍繞在 SEO 與使用者體驗上。歡迎隨時找我們聊聊!