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 許可證)