Nx vs Turborepo,怎麼在大型 Monorepo 優化開發體驗?

C.T. Lin
Dcard Tech Blog
Published in
10 min readMar 22, 2022

隨著前端的程式碼規模在 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 年的歷史。

npmtrends 比較圖

我自己是 Vercel 的信徒,但在做技術選擇時還是不能偏頗,在前不久試用 Turborepo 時,雖然設定簡潔而且也有達到基本的功能,但在與 Nx 相比之下還是能明顯感覺到兩者的優劣。

即便 Turborepo 是用 Go 撰寫,Nx 是使用 TypeScript 撰寫,但在大部分情況下,包括 cache 完全命中的情境,Nx 的速度都略勝 Turborepo,詳細的benchmark 可參考這個 repo,我們自己實測的時候也是有觀察到類似的結果。

再來一個是我覺得最致命的地方,Turborepo 會在 terminal 輸出加上所屬 task 的前綴,從下面這張圖就可以大致看得出來。更尷尬的是,當平行在跑多個 task 的時候,task 的 terminal 輸出會交錯出現幾乎不可讀,且輸出很長也不會收合。

出自 https://github.com/vsavkin/large-monorepo

以相依性分析來比較,Nx Graph 能產生一個互動式介面,讓你搜尋、過濾、查看你想了解的相依性部分,對於新人進入專案非常有幫助。而在這個功能上,Turborepo 目前只能用指令產生一張 Graphviz DAG 圖檔,在我們的規模下,100 個節點跟數不清的線會靠在一起幾乎不可辨識。

nx graph 功能

再來就是設定的細緻程度,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:testnx affected:e2e 來在 PR 只跑相關的測試。

除此之外,Nx 提供的 Jest executor 裡面有個很有趣的 batch 實作,可以利用 Jest CLI 一次跑多個 project 的功能來做執行上的優化,目前這個 NX_BATCH_MODE 看似還有不少問題,不過也值得繼續研究。

進階技巧

Binning vs DTE(Distributed Task Execution)

如果 CI 必須執行的 task 很多、執行時間也不短,為了節省開發者等待的時間,最好是能同時執行多個 task。

最簡單的方法當然就是直接拆分工作量,例如手動拆分或是透過計算自動化拆分:

Binning

透過 nx print-affected 可以得到被影響到需要重跑的 task,然後就可以按照這個清單去分配。

不過這樣做也有顯而易見的缺點,例如:相依性複雜的 workspace 無法分開 build、統整測試涵蓋率報告的流程複雜。

另一個在大型 monorepo 常見的策略是 DTE,分散式的執行 task,其訣竅在於需要有一個角色負責協調,把子任務分別派發去給數個 agent 去做,並收回產生的檔案跟 terminal 輸出:

DTE

在 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 卻執行得非常快,那有可能反而帶來反效果。

特別感謝同事 EvanTommy 幫忙校稿、以及 EJ 幫忙繪制精美的封面圖!

另外工商一下,Dcard 正在徵才中!我們正在尋找 Web Frontend Developer 夥伴加入我們。團隊常遇到 Performance (Web Vitals)、SSR 與 i18n 相關挑戰,以及如何以良好的架構去解決實際問題。最終目的圍繞在 SEO 與使用者體驗上。歡迎隨時找我們聊聊!

--

--

C.T. Lin
Dcard Tech Blog

Architect @ Dcard. Author of Electron React Boilerplate and Bottender. JavaScript Developer.