現代軟體架構的集權藝術: 將多個 Python 後端整併成 monorepo 架構的實戰心法

現代軟體架構的集權藝術: 將多個 Python 後端整併成 monorepo 架構的實戰心法

文章重點:

  • Polyrepo(多儲存庫) 讓每個服務各有獨立倉庫。是傳統專案最直觀、最常見的架構
  • Monorepo(單一儲存庫) 則是把多個服務與共用模組放進同一個 Git 倉庫的專案架構。
  • 當「跨專案共用的功能」越來越多(例如 RBAC、檔案處理、共用工具),Polyrepo 的同步成本會指數上升,這通常是轉向 Monorepo 的分水嶺。
  • Pants 是一套為 Monorepo 設計的建置系統,能跨子專案統一執行 lint / format / typecheck,並用「resolve」隔離各專案的相依版本。
  • git-filter-repo + subtree merge 能在整併倉庫的同時保留每個原始 commit 與 git blame,不會把歷史壓成單一 commit。

一、前言

在軟體開發與團隊擴張的過程中,專案倉庫(Repository)的管理架構往往會走向一個關鍵的分水嶺。許多企業在推行微服務初期,為了開發方便,直覺採用了 Polyrepo(多儲存庫) 架構。

然而隨著服務與共用架構越來越多,微服務變得分散與碎片化,跨專案的管理與維護成本將會呈指數上升。

試想:當十個專案共用的 RBAC 管理功能突然要調整時,如果要逐一去每個專案修改、再去檢查內容是否對齊,那對維運來說簡直是地獄般的折磨。

因此,像 Google、Meta 等軟體大廠開始採用 Monorepo(單一儲存庫) 架構管理專案,將所有的程式碼、模組放進同一個倉庫,把分散的功能轉變成可重複引用的模組,避免在多個程式碼庫之間協調更新的麻煩。

本文將分享真實的整併經驗:把兩個各自演進半年、技術棧不同的 Django/DRF 後端整併成同一個 Monorepo。我會比較 Monorepo 與 Polyrepo 的利弊,引進新一代建置系統 Pants 來破解單一倉庫的效能與相依魔咒,最後實戰示範如何透過 git-filter-repo 進行 commit 遷移、保留歷史提交紀錄。

二、Monorepo 與傳統 Polyrepo 比較

什麼是 Polyrepo?

Polyrepo(多儲存庫) 指的是每個專案或微服務各自擁有獨立的生命週期、版本控制與程式碼倉庫。這是最常見、也最直覺的架構:新服務開一個新 repo,團隊各管各的,互不干擾。

優勢很明顯:倉庫小、權限好切、CI 跑得快、團隊邊界清楚。

但,問題出在「共用」。

當多個倉庫依賴同一段邏輯(例如登入授權、RBAC 權限模型、共用工具函式),通常只有兩種選擇,而兩種都不好受:

  1. 複製貼上
    每個 repo 各留一份。短期最快,長期最痛。
    同一個 bug 要修 N 次,且各副本會悄悄漂移(drift)到彼此不一致。
  2. 抽成套件
    把共用碼發成內部套件(private package)。
    乾淨,但每次改動都要「改套件 → 發版 → 各 repo 升版 → 測試」,
    一個小修正就是一輪跨倉庫的版本協調。

而 Monorepo,正是為了解決這些共用問題而誕生的架構。

什麼是 Monorepo?

Monorepo(單一儲存庫) 指的是把多個專案、服務與共用模組放進同一個 Git 倉庫,以同一份歷史、同一套工具鏈管理。

要注意的是,它不等於把所有程式碼耦合在一起;好的 Monorepo 仍維持清楚的模組邊界,只是讓「共用」變成一次 commit 就能跨專案生效的事。

以本次整併的專案為例,倉庫結構大致如下:

ignsw_bk_monorepo/
├── pants.toml                       # 建置設定:跨子專案的 lint / format / typecheck
├── docker-compose-{local,indev,usertest,prod}.yml
├── libs/
│   └── common/                      # 共用 lib:兩個子專案都以「路徑相依」引用
│       └── ignsw_common/            # exception / utils / manager / model / serializer ...
└── projects/
    ├── service_a/                   # 子專案 A:Python 3.11 + Django 5.2,獨立 pyproject / Dockerfile
    └── service_b/                   # 子專案 B:Python 3.12 + Django 6.0,獨立 pyproject / Dockerfile

關鍵在於 libs/common
以前要在兩個倉庫各維護一份的 RBAC、檔案處理、共用工具,現在收斂成一份來源(single source of truth),兩個子專案直接引用。

改一次,兩邊同時生效,也同時被同一套測試守住。

Monorepo vs Polyrepo 對照表

面向Polyrepo(多儲存庫)Monorepo(單一儲存庫)
共用程式碼複製或發內部套件,易漂移單一來源,一次改動跨專案生效
跨專案重構需多倉庫協調、逐一升版一個 PR 即可同步修改與測試
程式碼可見性各倉庫互相看不到全域可搜尋、可追溯
相依版本管理天然隔離需建置工具刻意隔離(見第二節的 pants)
權限控管以倉庫為單位,簡單需目錄層級權限或 CODEOWNERS
CI/Build 成本倉庫小、天然增量倉庫大,需建置工具做增量與快取
上手門檻需要工具與規範投資

什麼時候該從 Polyrepo 轉向 Monorepo?

Monorepo 不是銀彈。一個務實的判斷標準是:「跨專案共用的東西,是否多到讓同步本身變成主要成本?」

  • 如果你的服務彼此獨立、幾乎不共用邏輯
    → 那 Polyrepo 就夠了。
  • 如果你發現自己反覆在多個 repo 之間複製同一段修正、或維護一堆內部套件的版本矩陣
    → 那是該考慮一下 Monorepo。

以我們的實際經驗來說,像是 Redis、RBAC 等模組設計在兩個專案幾乎高度重疊,卻分散在兩個倉庫各自演進。整併進 Monorepo 後,這些共用碼收斂到 libs/common,但每個子專案依然能獨立部署、各自選擇 Django/Python 版本

三、Monorepo 管理工具:Pants

為什麼 Monorepo 需要專門的建置工具?

Monorepo 最常被質疑的就是「效能」。

倉庫一大,全量 lint、全量測試、全量 build 都會變慢;而且不同子專案的相依套件版本可能互相衝突。建置系統(build system) 就是為了破解這兩件事而存在:

  1. 增量與快取:只對「真正受影響」的檔案做 lint / typecheck / test,而不是每次全跑。
  2. 相依隔離:讓同一個倉庫裡的子專案各自鎖定不同的套件版本,互不干擾。

Pants 是專為 Monorepo 設計的建置系統,原生支援 Python,並透過「依賴推斷(dependency inference)」自動分析 import 關係,決定哪些 target 受到影響。

實務經驗:Pants 只做 lint,不做測試

這裡是很重要的務實取捨。在這次整併中,Pants 只負責跨子專案統一執行程式碼風格檢查 ( lint / format / typecheck )

./pants lint  ::    # 跨整個 Monorepo 跑 ruff(lint + format 檢查)
./pants fmt   ::    # 自動格式化
./pants check ::    # mypy 型別檢查

Pants 當然可以做測試,但為什麼這樣抉擇?因為這兩個 Django 專案的設定(settings)仰賴 SECRET_KEY、資料庫連線等環境變數,由各環境的 .env 檔注入,難以在 Pants 的隔離沙盒裡忠實重現。強行讓測試在沙盒裡跑,反而會製造出與正式環境不一致的假陽性。認清工具的邊界、只用它最擅長的部分,往往比硬要 all-in 更穩。

用「resolve」隔離各專案的相依版本

Pants 用 resolve 的概念解決相依衝突——每個 resolve 對應一份獨立的 lockfile。本專案宣告了多個 resolve:

Resolve對應用途
service_a子專案 A 的 requirements.lockPython 3.11 / Django 5.2 相依
service_b子專案 B 的 requirements.lockPython 3.12 / Django 6.0 相依
ignsw_common共用 lib 的 requirements.locklib 自身 lint/typecheck 用
ruff工具專用 resolve鎖定 ruff 版本(見下方)

共用 lib 的程式碼透過 parametrize 同時暴露給多個 resolve,子專案 import ignsw_common.* 時,Pants 的依賴推斷會自動鎖到「同一個 resolve」的變體。

讓兩個子專案共用同一份原始碼,卻各自綁定不同的 Django/Python 版本,正是 Monorepo 該有的樣子:共用邏輯,獨立相依。

四、保留你的專案紀錄:git-filter-repo

整併倉庫時,最容易被犧牲掉的就是 Git 歷史。如果只是把舊專案的檔案複製進新倉庫、再 commit 一次,那麼所有 git loggit blame 都會指向「整併當天的你」,原作者是誰、某行程式碼為什麼這樣寫、當初的 commit 訊息——全部消失。對需要追責、追問題根因的團隊來說,這是不可逆的損失。

Git 雖然有 subtree 指令,可以執行 git subtree add --squash 整併歷史提交,但這會把整段歷史壓縮成單一 commit。倉庫是整併了,但編輯紀錄 (blame) 就會全部消失。

雖然 Git 官方也有提供另一個歷史改寫工具 git filter-branch,但遇到大型 repo,它又慢又脆弱,容易出現許多 BUG。

因此,新的 git-filter-repo 工具如今已是官方推薦的新寵。它能把舊倉庫的完整歷史改寫成「彷彿一開始就活在新倉庫子目錄底下」的樣子,再併入 Monorepo。

實戰步驟

以我們的整合經驗來說,整體流程大致可分為:

  1. 在副本上改寫路徑
  2. merge 進 Monorepo:如果你是用 AI Agent 協助,最好請它將所有改寫都在 /tmp 的 clone 副本上進行,並用 chmod 將原始倉庫讀寫權限改成唯讀,一個位元都不動。
# 1) 用 --no-local clone 出一份隔離副本(不會 hardlink 回原始 .git)
git clone --no-local ~/path/to/ServiceA /tmp/service-a-history
cd /tmp/service-a-history

# 2) 把原本位於 src/ 的程式碼提升到 repo 根
git filter-repo --path-rename src/:

# 3) 再把整段歷史改寫到目標子目錄底下
git filter-repo --to-subdirectory-filter projects/service_a

# 4) 回到 Monorepo,以「允許不相關歷史」的方式 merge 進來
cd /path/to/monorepo
git remote add tmp /tmp/service-a-history
git fetch tmp
git merge --allow-unrelated-histories tmp/main \
  -m "Merge ServiceA history into projects/service_a"
git remote remove tmp

對第二個(以及未來每一個)子專案重複同樣兩步即可。本次整併就用這個流程,把兩個 repo 各自累積的完整 commit 歷史改寫後併入 Monorepo:

Merge ServiceA history into projects/service_a
Merge ServiceB history into projects/service_b

整併完成後,在 Monorepo 根目錄對任一被搬移的檔案執行 git log / git blame,理論上應該要能追到原始的作者、commit 訊息與日期

git log -- projects/service_a/server/settings.py
# 看得到整併前每一筆原始 commit,而不是只有「整併」這一筆

幾個務實提醒

  • 改寫後 SHA 會變filter-repo 重寫歷史會改變 commit hash,這是已知的取捨。blame 仍指向原作者,但若需要對照原 repo,建議在 merge commit 訊息裡附上原始 HEAD SHA。
  • 保護原始倉庫:動手前先 chmod -R a-w 把來源 repo 設唯讀、再打一份 tar 快照,雙重保險。
  • 走 dedicated branch:整段 merge 在專用分支上做、打一個 pre-history-merge tag;驗證沒問題再 fast-forward 回主線,不滿意就 git reset --hard 丟掉。
  • tag 命名空間:來源 repo 的 tag 會被一起改寫;若不想污染 Monorepo 的 tag 命名空間,可用 --tag-rename 為來源 tag 加 prefix(例如 service_a/)。

五、總結

從 Polyrepo 走向 Monorepo,本質上是一個 「用前期的工具與規範投資,換取後期跨專案維護成本」 的決策。

最後總結一下這次整併的幾個關鍵心法:

  1. 共用碼多到同步變成主要成本時,就該考慮 Monorepo。 反覆複製修正、維護一堆內部套件版本矩陣,都是訊號。
  2. Monorepo ≠ 全部耦合。 好的 Monorepo 仍讓子專案獨立部署、獨立鎖相依版本——共用邏輯,但各自選擇 Django / Python 版本,各自匯出 image。
  3. 用建置工具破解效能與相依困境。 Pants 透過 resolve 隔離相依、依賴推斷做增量;但要認清工具邊界(本專案讓 Pants 只管 lint,測試交給 Docker Compose)。
  4. 工具版本要對齊。 Ruff 在 Pants 與編輯器間版本不一致,會製造假性紅綠燈——Monorepo 的一致性要從工具版本先做起。
  5. 整併歷史用 git-filter-repo,別用 squash。 在副本上改寫、保留每一筆 commit 與 blame,原始倉庫全程唯讀。

如果你的團隊正站在這個分水嶺上,不用急著一次到位。可以先把「最痛的共用模組」抽進一個共用 lib、用建置工具守住一致性、用 git-filter-repo 保住歷史。

Monorepo 目的是成為降低維護成本的槓桿,而不是另一個難以駕馭的大泥球。

六、常見問題(FAQ)

Q:Monorepo 和 Polyrepo 最大的差別是什麼?
A:Polyrepo 讓每個服務各有獨立倉庫,天然解耦但共用碼難同步;Monorepo 把多個服務與共用模組放進同一倉庫,共用碼一次改動即可跨專案生效,但需要建置工具來隔離相依與維持效能。

Q:採用 Monorepo 之後,子專案還能各自獨立部署嗎?
A:可以,而且應該。Monorepo 只代表「共用同一份原始碼與歷史」,不代表綁在一起發布。本文的專案讓每個子專案各有獨立的 pyproject.tomlDockerfile 與相依鎖檔,各自匯出 image、各自部署。

Q:為什麼 Monorepo 需要 Pants 這類建置工具?
A:倉庫變大後,全量 lint / 測試 / build 會變慢,且不同子專案的套件版本可能衝突。Pants 提供增量建置、快取,以及用「resolve」隔離各子專案相依版本的能力。

Q:git-filter-repo 和 git subtree –squash 該選哪個?
A:若你想保留每一筆原始 commit 與 git blame,用 git-filter-repo(改寫歷史、保留作者)。git subtree --squash 會把整段歷史壓成單一 commit,blame 會遺失,只適合不在意歷史的情境。

Q:用 git-filter-repo 整併,會改到原本的舊倉庫嗎?
A:不會——前提是你照正確做法操作:先用 git clone --no-local 複製一份隔離副本,所有改寫都在副本上進行,原始倉庫設為唯讀並保留快照。

Loading

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *