繁體中文(台灣)
  • typescript
  • monorepo

TypeScript 一體化:Monorepo 的挑戰與收益

在本文中,我不會比較 monorepo 和 polyrepo,因為這完全是關於哲學。相反,我會專注於構建和進化的經驗,並假設你已經熟悉 JS/TS 生態系統。

Gao
Gao
Founder

介紹

我一直夢想著 monorepo。

在我為 Airbnb 工作時,我看到了 monorepo 的方法,但只限於前端。出於對 JavaScript 生態系統和“愉快”TypeScript 開發體驗的深愛,大約三年前,我開始將前端和後端代碼統一成同一語言。這對於招聘來說很棒,但在開發方面就不那麼棒了,因為我們的項目仍舊散落在多個 repo 中。

俗話說:「重構項目的最佳方法是重新開始一個」。所以當我在大約一年前創立我的初創公司時,我決定採用全面的 monorepo 策略:將前端和後端項目,甚至是數據庫架構放到一個 repo 中。

在這篇文章中,我不會比較 monorepo 和 polyrepo,因為這完全是關於哲學。相反,我會專注於構建和進化的經驗,並假設你已經熟悉 JS/TS 生態系統。

最終結果在 GitHub 上可用。

為什麼選擇 TypeScript?

坦白說,我是 JavaScript 和 TypeScript 的粉絲。我喜歡它的靈活性和嚴謹性的兼容性:你可以退回到 unknownany(雖然我們在代碼庫中禁止任何形式的 any),或者使用超嚴格的 lint 規則集來統一團隊的代碼風格。

當我們之前談論“全棧”概念時,我們通常會想象至少兩個生態系統和編程語言:一個用於前端,一個用於後端。

有一天,我突然意識到它可以更簡單:Node.js 快得足夠(相信我,在大多數情況下,代碼質量比運行速度更重要),TypeScript 足夠成熟(在大型前端項目中運行良好),而 monorepo 的概念已被許多知名團隊(React、Babel 等)實踐——那麼為什麼不把所有代碼從前端到後端結合在一起呢?這可以讓工程師在一個 repo 中完成工作而不需要上下文切換,並(幾乎)在一種語言中實現完整功能。

選擇包管理器

作為一名開發者,習慣性地,我迫不及待地想要開始編碼。但這次,事情有所不同。

包管理器的選擇對 monorepo 中的開發體驗至關重要。

惰性的痛苦

那是 2021 年 7 月。我從 [email protected] 開始,因為我已經使用它很久了。Yarn 很快,但我很快遇到了 Yarn Workspaces 的幾個問題。例如,未正確提升依賴,以及大量標記為「fixed in modern」的問題,這些都將我重定向到 v2(berry)。

「好吧,我現在就升級。」我停止在 v1 上掙扎,並開始遷移。但 berry 的長遷移指南嚇到了我,在經歷了數次失敗的嘗試後,我放棄了。

它就這麼運行了

於是包管理器的研究開始了。在試用了 pnpm 後,我被它吸引:它像 yarn 一樣快,本地支持 monorepo,命令類似於 npm,硬鏈接等。最重要的是,它就這麼運行了。作為一名開發者,我想要的是開始一個產品,而不是開發包管理器,我只是想添加一些依賴並開始項目,而不需要了解包管理器如何運作或任何其他花哨的概念。

基於同樣的想法,我們選擇了一個老朋友 lerna 來在包之間執行命令和發布工作空間包。

定義包範圍

一開始很難明確搞清每個包的最終範圍。根據現狀,儘量從最佳嘗試開始,記住你可以隨時在開發過程中進行重構。

我們的初始結構包含四個包:

  • core:後端綜合服務。
  • phrases:i18n 鍵 → 短語資源。
  • schemas:數據庫和共享 TypeScript 架構。
  • ui:與 core 交互的網頁 SPA。

全棧技術棧

由於我們正在擁抱 JavaScript 生態系統並使用 TypeScript 作為我們的主要編程語言,很多選擇就很直接了(根據我的喜好😊):

  • koajs 作為後端服務(核心):在 express 中使用 async/await 的經驗不太好,因此我決定使用具有本地支持的工具。
  • i18next/react-i18next 用於 i18n(短語/界面):喜歡其簡單的 API 和良好的 TypeScript 支持。
  • react 用於 SPA(界面):只是個人喜好。

那麼架構呢?

這裡仍然缺少一些東西:數據庫系統和架構 <-> TypeScript 定義映射。

通用 v.s. 有見解的

在那時,我嘗試了兩種流行的方法:

  • 使用包含大量裝飾器的 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。

開發體驗

包和配置共享

內部依賴

pnpmlerna 在內部工作空間依賴方面做得非常出色。我們在項目的根目錄使用下面的命令來添加兄弟包:

這將把 @logto/schemas 作為依賴添加到 @logto/core。在你的內部依賴的 package.json 中保持語義化版本,pnpm 也可以正確地將它們鏈接到 pnpm-lock.yaml 中。結果會是這樣的:

配置共享

我們將 monorepo 中的每個包視為“獨立”的。因此,我們可以使用標準方法進行配置共享,其中包括 tsconfigeslintConfigprettierstylelintjest-config。請參見此項目作為例子。

編碼、lint 和提交

我使用 VSCode 進行日常開發,簡單來說,當項目配置正確時,沒什麼不同:

  • ESLint 和 Stylelint 正常運作。
    • 如果你使用的是 VSCode ESLint 插件,添加以下 VSCode 設置以使其遵循每個包的 ESLint 配置(用你的值替換 pattern):
  • husky、commitlint 和 lint-staged 按預期工作。

編譯器和代理

我們為前後端使用不同的編譯器:parceljs 用於 UI(React)和 tsc 用於所有其他純 TypeScript 包。如果你還沒試過 parceljs,我強烈推薦你試試。這是一個真正的“零配置”編譯器,能夠優雅地處理不同的文件類型。

Parcel 主持自己的前端開發服務器,生產輸出文件只是靜態文件。由於我們想在同一來源下掛載 API 和 SPA 以避免 CORS 問題,下面的策略有效:

  • 在開發環境中,使用簡單的 HTTP 代理將流量重定向到 Parcel 開發服務器。
  • 在生產中,直接提供靜態文件。

你可以在這裡找到前端中介軟件函數實現。

監看模式

我們在 package.json 中為每個包設置了一個 dev 腳本,以便在需要時監視文件變化並重新編譯。多虧了 lerna,使用 lerna exec 平行運行包腳本讓事情變得簡單。根腳本將如下所示:

總結

理想情況下,新工程師/貢獻者入門只需兩步:

  1. 克隆 repo
  2. pnpm i && pnpm dev

結尾筆記

我們的團隊已經在這種方法下開發了一年,我們非常滿意。訪問我們的 GitHub repo 查看項目的最新狀況。總結一下:

挑戰

  • 需要熟悉 JS/TS 生態系統
  • 需要選擇合適的包管理器
  • 需要一些額外的一次性配置

收益

  • 在一個 repo 中開發和維護整個項目
  • 簡化的編碼技能需求
  • 共享的代碼風格、結構、短語和工具
  • 提高的通信效率
    • 不再有類似「API 定義是什麼?」的問題
    • 所有工程師都使用同一種編程語言
  • CI/CD 更容易
    • 使用相同的工具鏈進行構建、測試和發布

本文保留了若干未涵蓋的主題:從頭設置 repo、添加新包、利用 GitHub Actions 進行 CI/CD 等。如果我要展開其中的每一個,本文會變得過長。如有意見,請隨時評論並告訴我你想在未來看到哪個主題。