TypeScript 一應俱全:Monorepo 伴隨的苦與樂
在這篇文章中,我不會比較 monorepo 和 polyrepo,因為這純粹是哲學問題。相反,我將專注於構建和演變的經驗,並假設你熟悉 JS/TS 生態系統。
介紹
我一直有一個關於 monorepo 的夢想。
當我在 Airbnb 工作時,我看到過 monorepo 方法,但那只是在前端使用。由於對 JavaScript 生態系統的深厚愛好,以及“快樂”的 TypeScript 開發經驗,我從大約三年前開始在同一語言中對齊前後端代碼。這對於招聘來說很棒,但對於開發來說不那麼理想,因為我們的項目仍然分散在多個存儲庫中。
正如所說,“重構一個項目的最佳方式是開始一個新項目”。所以當我大約一年前開始我的創業公司時,我決定使用一個完整的 monorepo 策略:將前端和後端項目,甚至數據庫架構放入一個存儲庫中。
在這篇文章中,我不會比較 monorepo 和 polyrepo,因為這純粹是哲學問題。相反,我將專注於構建和演變的經驗,並假設你熟悉 JS/TS 生態系統。
最終結果可以在 GitHub 上看到。
為什麼選擇 TypeScript?
坦白說,我是 JavaScript 和 TypeScript 的粉絲。我喜歡其靈活性和嚴謹性的兼容性:你可以回退到 unknown
或 any
(儘管我們在代碼庫中禁止任何形式的 any
),或者使用超嚴格的 lint 規則集來統一團隊的代碼風格。
當我們之前談到“全棧”這個概念時,我們通常會想到至少兩個生態系統和程式語言:一個用於前端,一個用於後端。
有一天,我突然意識到事情可以更簡單:Node.js 快得足夠(相信我,在大多數情況下,代碼質量比運行速度更重要),TypeScript 成熟得足夠(在大型前端項目中表現良好),而 monorepo 概念已被許多著名團隊(React、Babel 等)所實踐——那麼為什麼不將所有代碼組合在一起,從前端到後端呢?這可以讓工程師在一個存儲庫中不進行上下文切換地完成工作,並用(幾乎)一種語言實現完整的功能。
選擇包管理器
作為一名開發者,通常迫不及待想開始編碼。但這次情況不一樣。
包管理器的選擇對 monorepo 的開發體驗至關重要。
惰性之痛
那是 2021 年 7 月。我使用 yarn@1.x
開始,因為我已經使用很久了。Yarn 很快,但不久我就遇到幾個 Yarn Workspaces 的問題。例如,沒有正確提升依賴,還有大量標記為“fixed in modern”的問題,這將我引導至 v2(berry)。
"好吧,我現在就升級。"我停止了與 v1 的鬥爭,開始遷移。但 berry 的長遷移指南嚇到了我,幾次嘗試失敗後我放棄了。
一切正常
於是我開始研究包管理器。經一次試用後,我被 pnpm
吸引住了:和 yarn 一樣快,本地 monorepo 支援,類似於 npm
的命令,硬鏈接等。最重要的是,它就是有效。作為一名想著手產品的開發者而不是開發包管理器,我只想添加一些依賴並開始項目,而不需要了解包管理器的工作原理或其他花哨的概念。
基於同樣的想法,我們選擇了一位老友 lerna
來執行跨包命令並發布工作區包。
定義包範圍
一開始很難清楚地確定每個包的最終範圍。根據現狀用你的最佳嘗試開始,記住你可以在開發過程中隨時重構。
我們的初始結構包含四個包:
core
:後端單體服務。phrases
:i18n 鍵 → 短語資源。schemas
:數據庫和共享 TypeScript 架構。ui
:與core
交互的 web SPA。
全棧技術棧
由於我們正在擁抱 JavaScript 生態系統並將 TypeScript 作為我們的主要程式語言,很多選擇都很直接(基於我的偏好 😊):
koajs
用於後端服務 (core):我在express
中使用async/await
的時候經歷過一段艱難的經歷,所以我決定使用具有原生支持的東西。i18next/react-i18next
用於 i18n (phrases/ui):喜歡其 API 的簡單性和良好的 TypeScript 支持。react
用於 SPA (ui):純屬個人偏好。
那架構呢?
某些東西仍然缺失:數據系統和架构 <-> TypeScript 定義映射。
一般與有意見
在那時,我嘗試了兩種流行的方法:
- 使用帶有大量裝飾器的 ORM。
- 使用像 Knex.js 這樣的查詢生成器。
但兩者在之前的開發中產生了一種奇怪的感覺:
- 對於 ORM:我不是裝飾器的粉絲,另一種抽象的數據庫層會增加團隊的學習努力和不確定性。
- 對於查詢生成器:這像是在以某些限制(好的一面)寫 SQL,但這不是實際的 SQL。因此我們需要在許多場景中使用
.raw()
進行原始查詢。
然後我看到這篇文章:“停止使用 Knex.js: 使用 SQL 查詢生成器是反模式”。標題看起來咄咄逼人,但內容很棒。它強烈提醒我“SQL 是一種編程語言”,並且我意識到我可以直接寫 SQL(就像 CSS,我怎麼能錯過這個!)以便在不增加其他層次和降低功率的情況下利用原生語言和數據庫功能。
總結來說,我決定堅持使用 Postgres 和 Slonik(一個開源的 Postgres 客戶端),正如文章所述:
...允許用戶選擇不同數據庫方言的好處是微不足道的,而同時為多個數據庫開發的費用卻是巨大的。
SQL <-> TypeScript
直接編寫 SQL 的另一個好處是我們可以輕鬆將其作為 TypeScript 定義的單一真相來源。我寫了一個代碼生成器來將 SQL 架构轉譯為我們在後端中使用的 TypeScript 代碼,結果看起來不錯:
如果需要,我們甚至可以將 jsonb
與 TypeScript 類型連接起來,並在後端服務中執行類型驗證。
結果
最終的依賴結構看起來像這樣:
你可能注意到這是個單向圖,這極大地幫助我們保持一個明確的架構和當項目增長時擴展的能力。此外,代碼(基本上)全部在 TypeScript 中。
開發體驗
包和配置共享
內部依賴
pnpm
和 lerna
在內部工作區依賴上表現得非常出色。我們在項目根目錄中使用以下命令添加兄弟包:
這將把 @logto/schemas
作為依賴添加到 @logto/core
。在保持內部依賴的語義版本在 package.json
中,pnpm
也可以正確地在 pnpm-lock.yaml
中鏈接它們。結果將看起來像這樣:
配置共享
我們將 monorepo 中的每個包都視為“獨立的”。因此我們可以使用標準方法進行配置共享,涵蓋 tsconfig
、eslintConfig
、prettier
、stylelint
和 jest-config
。請參考這個項目作為示例。
代碼、lint 和提交
我每天使用 VSCode 進行開發,簡而言之,當項目配置正確時,沒有什麼不同:
- ESLint 和 Stylelint 正常工作。
- 如果你在使用 VSCode ESLint 插件,請添加以下 VSCode 設置,使其尊重每個包的 ESLint 配置(用你自己的“pattern”值替換):
- husky、commitlint 和 lint-staged 按預期工作。
編譯器和代理
我們為前端和後端使用不同的編譯器:用於 UI(React)的 parceljs
和所有其他純 TypeScript 包的 tsc
。如果你還沒有嘗試 parceljs
,我強烈建議你試試。這是一個真正“零配置”的編譯器,能優雅地處理不同的文件類型。
Parcel 自己托管其前端開發伺服器,生產輸出只是靜態文件。由於我們想把 API 和 SPA 加載在同一來源下以避免 CORS 問題,下面的策略有效:
- 在開發環境中,使用簡單的 HTTP 代理將流量重定向到 Parcel 開發伺服器。
- 在生產中,直接提供靜態文件。
你可以在這裡找到前端中間件函數的實現。
監視模式
我們在 package.json
中有一個 dev
腳本,用於每個包監視文件更改並在需要時重新編譯。得益於 lerna
,使用 lerna exec
並行運行包腳本變得非常容易。根腳本會看起來像這樣:
總結
理想情況下,讓新工程師/貢獻者入門只需兩個步驟:
- 克隆存儲庫
pnpm i && pnpm dev
結束語
我們的團隊在這種方式下開發了一年,並且對此感到非常滿意。訪問我們的 GitHub 存儲庫 以查看項目的最新形態。總結來說:
痛點
- 需要熟悉 JS/TS 生態系統
- 需要選擇合適的包管理器
- 需要額外的一次性設置
獲益
- 在一個存儲庫中開發和維護整個項目
- 簡化的編程技能需求
- 共享的代碼風格、架構、短語和實用工具
- 改善的溝通效率
- 不再有這樣的問題:API 定義是什麼?
- 所有工程師都用相同的編程語言溝通
- 輕鬆的 CI/CD
- 使用相同的工具鏈進行構建、測試和發布
這篇文章還有許多話題沒有涉及:從頭開始設置存儲庫、添加新包、利用 GitHub Actions 進行 CI/CD 等等。如果每個話題都展開,這篇文章會太長。隨時評論讓我知道你希望在未來看到哪個話題。