1. 引言
本周精讀的文章是 The many Benefits of Using a Monorepo。
現在介紹 Monorepo 的文章很多,可以分為如下幾類:直接介紹 Lerna API 的;介紹如何從獨立倉庫遷移到 Lerna 的;通過舉例子說明 Monorepo 重要性的。
本文屬於第三種,從 Android 與 IOS 的開發故事說明了 Monorepo 的重要性。
筆者之所以選擇這篇文章,不是因為其故事寫的好,而是認可這種具有普適性的解決思路。畢竟 Lerna 作為 Monorepo 的實現之一也並不盡善盡美,而不同場景對 Monorepo 依賴的原因、功能也有所不同,所以希望借這篇文章,從理論上解釋清楚為什么會產生 Monorepo,以及 Monorepo 可以解決哪些問題,這樣在工作遇到問題時,才能想清楚自己要的是什么。
2. 概述
作者的一個項目是 PDF 服務,簡稱 PSPDFKit,需要同時兼顧 Android 與 IOS 平台,項目的發展經歷了如下幾個階段。
初始階段
在 2011 到 2013 年間,PSPDFKit 僅支持 IOS 平台,但最終項目需要支持 Android,因此開了一個新倉庫放置 Android 代碼。Android 倉庫的代碼不僅在 UI 上不同,同時解析 PDF 文檔的核心代碼也不同,這是因為 IOS 平台上使用內置 PDF 渲染引擎同時做了一些業務拓展,但使用的 OC 代碼無法在 Android 使用。
最終新建了兩個倉庫 PSPDFKit-Android
與 Core
。
倉庫 Core 中代碼依賴 Android 平台 JNI 的支持,所以並不能實現 Core 一處修改,兩處都生效的願望,而我們又希望兩邊功能始終兼容,且減少分支過多帶來的潛在的沖突,因此花了很久才意識到應該將這兩個倉庫合並起來。
考慮使用 Monorepo
由於 Android 的整套流程自己控制的,因此總是可以快速修復用戶提出的 BUG,然而 IOS 提供的 CGPDF 總會遇上各種問題。所以在 2014 年,我們開啟了一個龐大的項目,重寫 IOS 的 Core 庫。有三中方式可供選擇:
- 在 IOS 代碼中引用
PSPDFKit-Android
。 - 將
PSPDFKit-Android
提取到Core
倉庫中並分別維護。 - 將 IOS 與 Android 代碼合並到一個倉庫中。
經過討論,最終作者的團隊選擇了第三種方案,因此目錄結構類似如下:
- ios-platform
- android-platform
- core
特例
Web 與后台服務代碼一直是一個特例,我們認為這些內容相對獨立,所以沒有將其代碼放置到 Monorepo 中。
直到一年后,開始探索 WebAssembly 時,PSPDFKit-web 模塊就出現了,因為可以利用 WebAssembly 將 Core 的代碼編譯並在 Web 平台使用,因此 Core 倉庫與 Web 倉庫的關系變得非常緊密,最終,我們將 Web、Server 也都遷移到 Monorepo 中了。
問題
Monorepo 瑕不掩瑜,但作者還是列舉了一些缺陷。
由於源碼在一起,倉庫變更非常常見,存儲空間也變得很大,甚至幾 GB,CI 測試運行時間也會變長。即便如此,團隊中任何人都不想回到 git submodules 多倉庫的方式。
3. 精讀
總的來說,雖然拆分子倉庫、拆分子 NPM 包(For web)是進行項目隔離的天然方案,但當倉庫內容出現關聯時,沒有任何一種調試方式比源碼放在一起更高效。
工程化的最終目的是讓業務開發可以 100% 聚焦在業務邏輯上,那么這不僅僅是腳手架、框架需要從自動化、設計上解決的問題,這涉及到倉庫管理的設計。
一個理想的開發環境可以抽象成這樣:
“只關心業務代碼,可以直接跨業務復用而不關心復用方式,調試時所有代碼都在源碼中。”
在前端開發環境中,多 Git Repo,多 Npm 則是這個理想的阻力,它們導致復用要關心版本號,調試需要 Npm Link。
另外對於多倉庫的缺點,文中還有一些沒有提到的因素,這里一並列舉出來:
管理、調試困難
多個 git 倉庫管理起來天然是麻煩的。對於功能類似的模塊,如果拆成了多個倉庫,無論對於多人協作還是獨立開發,都需要打開多個倉庫頁面。
雖然 vscode 通過 Workspaces 解決多倉庫管理的問題,但在多人協作的場景下,無法保證每個人的環境配置一致。
對於共用的包通過 Npm 安裝,如果不能接受調試編譯后的代碼,或每次 npm link 一下,就沒有辦法調試依賴的子包。
分支管理混亂
假如一個倉庫提供給 A、B 兩個項目用,而 B 項目優先開發了功能 b,無法與 A 項目兼容,此時就要在這個倉庫開一個 feature/b
的分支支持這個功能,並且在未來合並到主干同步到項目 A。
一旦需要開分支的組件變多了,且之間出來依賴關聯,分支管理復雜度就會呈指數上升。
依賴關系復雜
獨立倉庫間組件版本號的維護需要手動操作,因為源代碼不在一起,所以沒有辦法整體分析依賴,自動化管理版本號的依賴。
三方依賴版本可能不一致
一個獨立的包擁有一套獨立的開發環境,難以保證子模塊的版本和主項目完全一直,就存在運行結果不一致的風險。
占用總空間大
正常情況下,一個公司的業務項目只有一個主干,多 git repo 的方式浪費了大量存儲空間重復安裝比如 React 等大型模塊,時間久了可能會占用幾十 GB 的額外空間,對於沒有外接硬盤的同學來說,定期清理不用的項目下 node_modules
也是一件麻煩事。
不利於團隊協作
一個大項目可能會用到數百個二方包,不同二方包的維護頻率不同,權限不同,倉庫位置也不同,主倉庫對它們的依賴方式也不同。
一旦其中一個包進行了非正常改動,就會影響到整個項目,而我們精力有限,只盯着主倉庫,往往會栽在不起眼的二方包發布上。
所以對於一個非常復雜,又具有技術挑戰的大型系統在協作人員多的情況下出現問題的概率非常大,需要通過 Review 制度避免錯誤的發生,那么將所有相關的源碼聚合在一個倉庫下,是更好管理的。
理想 monorepo 的設計
參考 Lerna 的規范,以 packages
作為子模塊根文件夾,筆者設計一個理想的 monorepo 結構:
.
├── packages
│ ├─ module-a
│ │ ├─ src # 模塊 a 的源碼
│ │ └─ package.json # 自動生成的,僅模塊 a 的依賴
│ └─ module-b
│ ├─ src # 模塊 b 的源碼
│ └─ package.json # 自動生成的,僅模塊 b 的依賴
├── tsconfig.json # 配置文件,對整個項目生效
├── .eslintrc # 配置文件,對整個項目生效
├── node_modules # 整個項目只有一個外層 node_modules
└── package.json # 包含整個項目所有依賴
所有全局配置文件只有一個,這樣不會導致 IDE 遇到子文件夾中的配置文件,導致全局配置失效或異常。node_modules
也只有一個,既保證了項目依賴的一致性,又避免了依賴被重復安裝,節省空間的同時還提高了安裝速度。
兄弟模塊之間通過模塊 package.json
定義的 name
相互引用,保證模塊之間的獨立性,但又不需要真正發布或安裝這個模塊,通過 tsconfig.json
的 paths
與 webpack
的 alias
共同實現虛擬模塊路徑的效果。
再結合 Lerna 根據聯動發布功能,使每個子模塊都可以獨立發布。
4. 總結
Lerna 是業界知名度最高的 Monorepo 管理工具,功能完整。但由於通用性要求非常高,需要支持任意項目間 Monorepo 的組合,因此在 packages
文件夾下的配置文件還是與獨立倉庫保持一致,這樣在 TS 環境下會造成配置截斷的問題。同時包之間的引用也通過更通用的 symlink 完成,這導致了還是要在子模塊目錄存在 node_modules
文件夾,而且效果依賴項目初始化命令。
如果加一些限定條件,比如基於 Webpack + Typescript 環境的 Monorepo,可以換一套思路,利用這些工具自身運行時功能,減少更多模版代碼或配置文件,進一步提升 Monorepo 的效果。
對於別名映射,對 symlink 與 alias 進行對比:
- symlink: 更通用,適合任何構建器。但需要初始化,且在每個關聯模塊下新增
node_modules
文件夾。 - alias: 限定構建器。但不需要初始化,不新增文件夾,甚至可以運行時動態修改別名配置。
可見如果限定了構建器,別名映射可以做得更輕量,且無需初始化。
今天的問題是,你的項目需要使用 Monorepo 嗎?你對 Monorepo 有其他要求嗎?
如果你想參與討論,請 點擊這里,每周都有新的主題,周末或周一發布。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號

special Sponsors
版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)