Git 子项目共享代码:submodule / subtree / monorepo 怎么选

起因

我们有 5 个微服务都用一份"共享 utility 代码"(如 protobuf 定义 /
通用 model / shared logger)。三种主流方式:

A. 各项目 copy-paste(同步靠人工,最差)
B. 抽成 npm/pip 包发包(增加发版流程)
C. Git submodule 共享一个 repo
D. Git subtree 把共享 repo 嵌入主 repo
E. monorepo 把所有项目放一起

各自适用场景不同。

A. Git submodule

cd myservice
git submodule add https://github.com/myorg/shared-libs.git lib/shared
git commit -m 'add shared submodule'

.gitmodules 文件 + lib/shared 目录(指向 shared-libs 的某个 commit)。

# clone 主 repo 时要 init submodule
git clone --recurse-submodules https://github.com/myorg/myservice.git
# 或事后
cd myservice
git submodule update --init --recursive

更新 submodule:

cd lib/shared
git pull origin main
cd ../..
git add lib/shared
git commit -m 'bump shared submodule to abc123'

主 repo 记录 "我指向 shared 的哪个 commit"。

优点

  • 共享 repo 独立版本控制
  • 每个项目可固定某个版本(不同步升级)

缺点

  • 命令繁琐(git submodule update 经常忘)
  • 新成员第一次 clone 总踩坑(忘 --recurse-submodules
  • 改 submodule 内代码后要 push 共享 repo + 更新主 repo 引用 = 两个
    PR

B. Git subtree

cd myservice
git subtree add --prefix=lib/shared https://github.com/myorg/shared-libs.git main --squash

效果:把 shared-libs 整个内容 copy 到 lib/shared/ 目录,作为本 repo
的真实 commit(不是引用)。

# 拉 shared 上游更新
git subtree pull --prefix=lib/shared shared-remote main --squash

# 把本地 shared 改动推回上游
git subtree push --prefix=lib/shared shared-remote feature-x

优点

  • 普通 clone 拿到所有文件(无 submodule 双步骤)
  • 本地直接改 shared 代码 + commit(无需先 push 上游)
  • 简单:只是普通 git 文件

缺点

  • git log 看到 shared 的所有历史(噪音)
  • subtree push 偶尔合并冲突复杂
  • 大多数开发者不熟,命令易记错

C. monorepo

把所有服务和 shared 放一个 repo:

myorg-monorepo/
├── services/
│   ├── api/
│   ├── worker/
│   └── frontend/
├── packages/
│   ├── shared-models/
│   └── shared-utils/
└── tools/

shared 代码就是 packages/ 下的子目录,import 直接相对路径或 workspace
(pnpm / yarn workspace / npm workspace / Cargo workspace / Go
workspace / etc)。

优点

  • 改 shared + 改用它的 service 一个 commit + 一个 PR
  • 所有服务的 history / branch / CI 在同处
  • 重构跨服务时方便(IDE refactor 全 monorepo 生效)

缺点

  • repo 越来越大(clone 慢;shallow clone 缓解)
  • CI 要 path-based filter(不能"改 README 跑全部测试")
  • 工具支持差异(Bazel / Nx / Turborepo 等 monorepo build 工具学习成本)

D. 单独发包

shared 抽成 npm / pip 包发到内部 registry:

# shared 项目
npm publish --registry https://npm.internal.example.com

# 使用方
npm i @myorg/shared

优点

  • 标准化语言生态
  • 严格版本管理
  • 不同服务可用不同 shared 版本

缺点

  • 改 shared 要发版 → 更新 → 测试 → 多步骤
  • 内部 registry 要维护
  • 不适合 quick iteration

决策矩阵

场景 推荐
5-10 个服务 + 1 个公司 + 同一团队 monorepo
开源项目想用别人的 OSS lib submodule(少改)
想 vendor 别人代码 + 偶尔修改 subtree
多公司 / 跨组织共享 lib 单独发包
多团队 + 服务有独立发版周期 单独发包
想保持简单 + 改动频繁 monorepo

实际我的经验:绝大多数场景 monorepo 是最佳选择。submodule 是
"避免 monorepo 的 hack",长期维护成本高。

monorepo 工具

JavaScript / TypeScript:

# package.json
{
  "private": true,
  "workspaces": ["packages/*", "services/*"]
}
# 用 npm workspaces / pnpm workspace / yarn workspaces
pnpm install              # 装所有
pnpm -r build              # 在所有包里跑 build
pnpm --filter api dev      # 只对 api 包

更高级:Turborepo / Nx,加 task caching + 增量 build。

Python:用 uv workspace:

# pyproject.toml
[tool.uv.workspace]
members = ["packages/*", "services/*"]

Go:go workspace:

go work init
go work use ./service-a ./service-b ./pkg/shared

Rust:Cargo workspace 在 Cargo.toml [workspace] 里。

CI 优化(monorepo 必备)

GitHub Actions path filter:

on:
  pull_request:
    paths:
      - 'services/api/**'
      - 'packages/shared-models/**'
      - '.github/workflows/api.yml'

jobs:
  test-api:
    ...

只在 api 或它依赖的 shared 改动时跑 api 测试。

或者用 dorny/paths-filter action 做动态:

- uses: dorny/paths-filter@v3
  id: changes
  with:
    filters: |
      api: 'services/api/**'
      web: 'services/web/**'
      shared: 'packages/shared-*/**'

- if: steps.changes.outputs.api == 'true' || steps.changes.outputs.shared == 'true'
  run: pnpm --filter api test

避免改一行触发全部 CI。

我们的真实迁移

从 6 个独立 repo + Git submodule 到 monorepo:

submodule before monorepo after
新人 clone 步骤 5 步 1 步
跨 service 改动 6 个 PR 1 个 PR
build 时间(CI 全跑) 18min 12min(with cache: 4min)
repo 大小 6 × 50MB 280MB
全部签新版本 几小时 单 PR + tag

monorepo 显著简化。

踩过的坑

submodule 类

  1. 改 submodule 内代码忘 push:在 lib/shared 改了 + 主 repo
    commit 更新引用 + 推主 repo。同事 clone 后 lib/shared 拿到的
    commit 在 origin 不存在 → fail。先 push submodule,再 push 主 repo

  2. git checkout 切分支 submodule 不变:默认 git 不自动更新
    submodule 到目标分支记录的版本。git config submodule.recurse true
    让所有命令自动 recurse。

subtree 类

  1. subtree push 偶尔失败:主 repo 内 commit 太散,subtree 算 patch
    出错。先 squash 合并相关 commit 再 push。

monorepo 类

  1. 某 service 重 build 全 monorepo:必须做 path-based filter
  2. build cache。否则 monorepo 反而比独立 repo 慢。

  3. 依赖循环:service A 用 shared B,shared B 用 service A 的某
    helper(不该)→ 循环。monorepo workspace tool 应该报错;强制依赖
    方向 packages/ → services/,不反向。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。