☰
如何同步 .NET 虚拟单体仓库:多仓库构建与提交对齐实践
2026/10/20 4:16:58 网站建设 项目流程

“我们如何同步 .NET 的虚拟单体仓库”这个标题,听起来像是一篇基建团队的内部述职,其实最早是我们给一套代号叫“酒”的同步方案写的文档标题。代码库里没有真的放酒,而是因为我们当时开玩笑:多仓库同步要是做不好,团队只能靠酒精麻醉自己。后来方案跑顺了,发现这个过程确实像酿酒——仓库是原料,脚本是发酵桶,提交对齐才是装瓶。这篇文章是系列第一篇,我想先只讲同步这件事本身:为什么会有虚拟单体仓库,选型时怎么取舍,最终怎么落地到 .NET 的日常构建里。如果你也维护着几个相互依赖的 .NET 仓库,又暂时不想把它们合并成一个巨型单体仓库,这篇应该对你有用。

1. 先说清楚:虚拟单体仓库到底要解决什么问题

1.1 真实单体仓库与虚拟单体仓库的本质区别

真实单体仓库最好理解:一个 Git 仓库里面同时放着服务端、客户端、公共类库、部署脚本,提交是一条线,一个 PR 可以顺手改完三处代码再一起合入,版本之间天然同步。虚拟单体仓库则完全不同:代码仍然分布在多个独立 Git 仓库里,但通过一个控制仓库和同步脚本,把这些仓库聚合到同一个工作区根目录下,让dotnet build、dotnet test能一次处理全部项目。从开发者每天接触的文件树看,两者很接近,但底层的版本模型差别很大。

我们团队最初从视觉上被误导过:以为只要把仓库目录通过某种方式挂到一个根目录下,就算“虚拟单体仓库”了。后来发现,真正难的不是把目录聚在一起,而是让多个仓库的提交不停留在各自为政的状态。代码文件出现在同一个工作区只是表象,一堆互相不兼容的提交放在一起,构建出来的东西照样是坏的。

1.2 我们为什么没有直接合并成一个大仓库

很多团队第一反应是:既然多仓库同步这么麻烦,为什么不干脆把十几个 .NET 仓库合并成一个真 monorepo?我们在早期也认真评估过,最后没这么做的原因不是技术,而是组织边界。公共类库的维护者不希望每个业务团队都能随手改核心代码;基础组件和业务服务的发布节奏也不一样,前者一个月出一个稳定版本,后者可能需要一天发三次;再加上权限模型和历史提交梳理的工作量,合并一个仓库的成本远高于搭一套同步脚本。

虚拟单体仓库就是一个妥协产物:保留多仓库的独立边界,同时通过聚合层拿到真 monorepo 的本地开发体验。它不是银弹,但在我们当时的约束下,是性价比最高的路径。需要提醒的是,如果你们只有一个团队、代码总量不大、也没有复杂的权限需求,那直接合并可能是更好的选择。虚拟单体仓库不应当成为回避必要重构的借口。

1.3 “同步”真正同步的是组合提交

多仓库之间的代码并不仅仅是物理上出现在同一目录,更重要的是让它们处在同一组互相兼容的提交上。举个例子:公共类库 Platform 在上游改了一个 API,业务服务 Billing 在下游必须同步调用方式,如果两个仓库各自在任意时机更新到最新,就可能组合出一个无法编译的状态。这件事在真 monorepo 里被 Git 的原子提交天然解决了,但在虚拟单体仓库里必须自己解决。

所以我认为,虚拟单体仓库的核心不是“目录同步”,而是“组合提交同步”。我们需要一种机制,显式地记录某个时刻每个子仓库应该停在哪个提交上,并且让本机和 CI 都能根据这份记录复现同一套代码。没有这个机制,所谓的虚拟单体仓库不过是一个把互相打架的版本堆在一起的目录。

2. 三种同步方案怎么选:submodule、subtree、自定义清单

2.1 submodule:官方能力,但开发体验像开手动挡

Git 官方提供了 submodule,主仓库记录每个子仓库的 gitlink,也就是一个固定提交指针。它的优点是真的不需要额外工具,git submodule update --init --recursive就能拉下所有子仓库。但缺点也非常明显:开发者在主仓库切换分支时,子仓库很容易停留在旧提交;多个人同时更新子仓库时,主仓库会出现大量 gitlink 改动;CI 上如果忘记递归更新,整个解决方案只能加载一半项目。

在 .NET 场景里,我见过最多的问题是 submodule 目录是空的,但Wine.sln仍然记录着里面项目的相对路径,dotnet build直接报“项目文件不存在”。这种问题排查起来不难,但架不住每个新人都踩一遍。如果一定要用 submodule,我建议把子仓库的提交彻底固定,不要用git submodule update --remote去追远端分支,否则版本漂移会让你难以定位问题。但说实话,对于需要频繁跨仓库协作的团队,submodule 的日常摩擦太大了。

2.2 subtree:同步更彻底,但双向维护很痛苦

subtree 是另一种思路:把子仓库的历史直接合并进主仓库,子仓库的代码变成主仓库普通文件的一部分,不再需要单独的 gitlink。好处是 clone 主仓库后一切立即可用,没有递归更新、没有空目录,构建工具不会感知到“这原来是另一个仓库”。缺点藏在更新和回推里:要把子仓库的上游变更拉回来,需要执行 subtree merge;要把主仓库内的改动推回子仓库,又需要 subtree split 重写一段历史。

我在实践中发现,subtree 适合低频更新的公共库,不适合多个业务仓库之间高频双向同步。想象一下,你每周都要从上游拉一次公共库,同时又把一批修复反向推回上游,双方提交交织在历史里,没过多久你自己也分不清哪些目录是 subtree、哪些是普通文件。一旦出现复杂冲突,解决成本比 submodule 高得多。所以在选型对比里,subtree 目前只适合少量、稳定、低频的依赖场景。

2.3 manifest 同步器:把控制权收回到自己手里

两种官方方案都不理想,最后我们选择了自研一个很薄的 manifest 同步器。思路非常简单:在控制仓库里维护一个清单文件,声明每个子仓库的 URL、挂载路径、目标分支或提交;同步脚本读取这个清单,依次执行git fetch、git checkout,并生成一份 lockfile 记录实际检出的完整 SHA。这样整个组合状态是显式的,团队能看懂,CI 也能复现。

如果你不愿意从零开始,其实也可以找现成的类 repo 工具,不过对大多数团队来说,用 PowerShell 或 Python 写一个几十行的脚本往往更贴合现状。自研方案最大的好处是灵活:你可以加 override 分支用于跨仓库 PR 集成,可以生成 lockfile 用于版本对齐,可以控制深浅克隆策略。代价同样明确:所有故障都得自己处理,包括网络中断、并发同步、权限问题。这个选择本质上不是“官方 vs 自研”,而是“接受工具的默认行为”还是“自己承担控制权”。

3. 把多仓库拼成 .NET 解决方案的关键细节

3.1 聚合 .sln:仓库路径和 GUID 的管理

当多个仓库的工作区聚到同一个根目录后,下一步就是让 .NET 把它们看成一个整体。Sln 文件本质是一个文本文件,里面记录项目类型 GUID、项目 GUID、项目文件相对路径。跨仓库项目路径必须写成相对于 sln 文件的路径,而且最好保持稳定。我们内部统一把子仓库放到repos/core、repos/billing这种固定层级,避免有人在本地把仓库 clone 到不同的路径然后生成绝对路径,导致提交进 git 后别人没法用。

实际生成 sln 时,可以用dotnet new sln -n Wine和dotnet sln Wine.sln add repos/core/src/*.csproj repos/billing/src/*.csproj快速完成。需要注意,如果项目路径里包含通配符,某些 shell 环境需要加引号。还有一点新手容易忽略:不同仓库里的两个项目如果 GUID 重复,Visual Studio 和 dotnet 工具都会出现意外行为。正常情况下 Git 仓库的 GUID 不会重复,但如果你曾经复制过 csproj 文件,最好检查一下。

3.2 ProjectReference 与 NuGet 包引用的取舍

在虚拟单体仓库里,最顺滑的连接方式是用 ProjectReference。它让 MSBuild 直接知道项目间依赖关系,构建顺序自动排序,调试时还能跨仓库单步跟踪,源码改动即时生效。但 ProjectReference 会让子仓库之间在构建时强耦合:引用方依赖被引用方的源码,而非一个已发布的包版本,子仓库独立打包时就会出现路径找不到或者版本不受控的问题。

我们的做法是用一个条件开关区分两种模式。虚拟单体仓库构建时传入UseProjectReferences=true,csproj 里走 ProjectReference;子仓库独立发布时使用 PackageReference,指向内部 NuGet 源上的稳定版本。写法类似:

<Choose> <When Condition="'$(UseProjectReferences)' == 'true'"> <ItemGroup> <ProjectReference Include="..\..\core\src\Platform.Core.csproj" /> </ItemGroup> </When> <Otherwise> <ItemGroup> <PackageReference Include="Org.Platform.Core" Version="2.3.0" /> </ItemGroup> </Otherwise> </Choose>

这个方案不是万能的,但它让同一套 csproj 在两个场景下都能工作。还有一点值得强调:虚拟单体仓库里的服务之间尽量不要互相 ProjectReference,而是通过公开的接口定义、消息契约来解耦。否则所有服务会像一堆黏土一样缠在一起,失去多仓库边界的意义。

3.3 全局属性注入:Directory.Build.props 的正确打开方式

控制仓库根目录下放一个 Directory.Build.props,是所有子项目都能隐式继承的全局属性入口。它可以统一TargetFramework、Nullable、LangVersion,还可以设置RestorePackagesPath把 NuGet 包集中到一个目录,避免多个仓库各自拉一份耗时的依赖缓存。听起来很方便,但我吃过亏:不要在 props 里把BaseOutputPath或BaseIntermediateOutputPath一刀切指向同一个集中目录。

原因是多个项目可能存在同名文件,把中间产物揉到一起会互相覆盖,尤其在并行构建时会出现莫名其妙的“文件被占用”或“已存在”错误。更安全的做法是保持项目默认的输出结构,只有当 Windows 长路径问题严重时,才单独给某些项目覆盖中间目录。统一属性可以解决版本漂移,但不能替 MSBuild 解决文件命名冲突,这个边界必须守清楚。

4. 实操:用代号“酒”完成一次虚拟单体仓库同步

4.1 建立控制仓库与 manifest 文件

假设我们现在有两个仓库,一个公共类库 Platform.Core,一个业务服务 Billing.Api。控制仓库取名叫 wine-root,里面只有三样东西:manifest.json、Wine.sln、sync.ps1。manifest 文件定义了两个子仓库应该挂到哪里、默认跟踪哪个分支。我们的初始版本长这样:

{ "repositories": [ { "name": "core", "url": "git@internal:net/platform-core.git", "path": "repos/core", "branch": "main" }, { "name": "billing", "url": "git@internal:net/billing-api.git", "path": "repos/billing", "branch": "main" } ] }

这个清单是整个同步方案的“唯一事实来源”。所有子仓库的挂载路径都从它派生,脚本只认这个文件。路径设计上尽量短而固定,因为后面 .sln 里的相对路径、ProjectReference 里的相对路径都依赖这个布局。控制仓库可以独立演进,manifest 文件的变更也走普通 PR 流程,这比直接改 submodule 指针要透明得多。

4.2 编写同步脚本:拉取、锁定、校验

同步脚本的核心逻辑并不复杂:对 manifest 里的每个仓库,如果目录不存在就 clone,存在就 fetch;然后根据当前环境决定 checkout 到分支最新还是 lockfile 里的固定 SHA;最后校验实际 HEAD 与期望值一致。我提供一个精简的 PowerShell 版本作为参考:

param( [string]$Manifest = "manifest.json", [string]$LockFile = "wine.lock.json" ) $manifest = Get-Content $Manifest -Raw | ConvertFrom-Json foreach ($repo in $manifest.repositories) { $target = Join-Path $PSScriptRoot $repo.path if (-not (Test-Path $target)) { git clone $repo.url $target } Push-Location $target git fetch --prune --tags origin if (Test-Path (Join-Path $PSScriptRoot $LockFile)) { $lock = Get-Content (Join-Path $PSScriptRoot $LockFile) -Raw | ConvertFrom-Json $repoLock = $lock.repositories | Where-Object { $_.name -eq $repo.name } if ($repoLock) { git checkout --detach $repoLock.commit } else { git checkout $repo.branch git pull --ff-only origin $repo.branch } } else { git checkout $repo.branch git pull --ff-only origin $repo.branch } Pop-Location }

第一次同步因为没有 lockfile,脚本会拉取分支最新版本;跑通后我们会把每个仓库的实际 SHA 写进 wine.lock.json。后续任何人、任何 CI 机器只要拿到 lockfile,就能还原到同一组提交。注意--ff-only是为了避免脚本在本地产生 merge commit,否则整个 checkout 结果依赖本地仓库的历史,难以复现。

4.3 生成 .sln 并完成构建

仓库全部挂载完成后,在控制仓库根目录执行这几步:

dotnet new sln -n Wine dotnet sln Wine.sln add repos/core/src/*.csproj repos/billing/src/*.csproj dotnet restore Wine.sln --locked-mode dotnet build Wine.sln -c Debug

dotnet restore --locked-mode的前提是有 packages.lock.json,这里用它来保证 NuGet 依赖也处于可复现状态。第一次执行时可以先不加这个参数,等依赖稳定后再生成锁文件。构建过程中如果出现跨仓库 ProjectReference 找不到项目,最可能的原因就是 manifest 里的挂载路径与实际 csproj 之间的相对层级不一致。例如 core 仓库的项目在repos/core/src,而 billing 里引用它时写..\..\core\src\Platform.Core.csproj,从repos/billing/src出发确实需要上两级再进入 core,这个相对路径要仔细核对。

4.4 在 CI 里跑同一条链

CI 上的执行流程和本地完全一致:拉取控制仓库,运行 sync.ps1,再执行 restore/build/test。这样每个 pipeline 本质上都在构建一个“组合提交”,而控制仓库中的 lockfile 就是这个组合提交的 ID。配置 CI 时我建议把子仓库的分支更新事件也触发生成新的组合构建,但不直接改动 lockfile,而是由构建成功后自动提交新的 lockfile。

这里有个容易犯晕的地方:如果普通开发者改了子仓库 main 分支,CI 会试图用最新代码构建整个虚拟单体仓库,可能失败。我们的做法是给每个子仓库的 main 分支加一条状态 badge:组合构建为绿色才认为该分支可合入。这样多仓库的“集成门禁”就从个人自觉变成了一个自动化约束。你可以根据自己团队流水线能力逐步加,一开始可以先只在主控制仓库每天定时触发一次全量同步构建。

5. 同步路上的常见坑与排查经验

5.1 子仓库“不听话”:永远停在旧提交

我遇到最多的现象是:本机跑 sync.ps1 一切正常,但 CI 上某些仓库就是停在旧提交。排查第一步就是看脚本是否只在目录不存在时才 clone,第二次运行完全没有 fetch。我们的脚本里虽然加了 fetch,但一开始没有--prune --tags,导致远端删除的分支和 tag 在本地残留,checkout 的分支名可能指向一个过期的远端引用。

建议在脚本中把 clone 后的目录用git rev-parse HEAD输出实际 SHA,和 lockfile 比较后打印差异。如果开发环境里目录已经存在并带有本地修改,脚本 checkout 可能会失败,CI 环境反而更干净,所以本地出现怪问题时不要觉得“一定是脚本没错”,先删掉对应目录重新 clone 一次,这是最快的排除手段。

5.2 Windows 路径过长导致项目加载失败

.NET 项目的普遍痛点:仓库层级一深,加上obj\Debug\net8.0这种中间目录,很容易超过 Windows 默认的 260 字符路径限制。虚拟单体仓库会放大这个问题,因为控制仓库本身就有几层目录,再挂repos/billing/src/Billing.Api,CI 代理通常还要包一层工作目录。常见的失败表现是 Visual Studio 无法加载项目或 build 报“文件路径过长”。

我们做了几件事:先把 manifest 里的挂载路径缩短为repos/billing这种两层结构;在 clone 后用git config core.longpaths true打开长路径支持;CI 代理的工作目录也尽量短,比如C:\w。如果仍然有问题,再针对个别项目设置中间输出目录到一个名为_b的短路径下。注意不要全局改,只对路径最深的几个项目做覆盖,同名项目冲突的概率会小很多。

5.3 并行持续集成带来的中间产物冲突

多个 CI agent 同时构建同一个虚拟单体仓库时,如果它们共享一个 NuGet 包缓存或输出目录,dotnet restore偶尔会报文件锁错误。这不算虚拟单体仓库独有问题,但多仓库聚合后更明显:因为一次 restore 要处理十几个项目,依赖图比单个仓库大,并行任务之间的包写入窗口冲突概率随之增加。

我的建议是每个 CI agent 使用独立的工作目录,并且把RestorePackagesPath指到 agent 本地的临时目录,例如$(System.DefaultWorkingDirectory)/.nuget。如果你们只有一台构建机且无法改共享缓存,可以暂时在 restore 命令上加--disable-parallel,这会变慢但能避免一部分锁冲突。归根结底,虚拟单体仓库的同步只负责让代码组合保持一致,构建环境的隔离还得靠 CI 基础设施自己维护。

5.4 跨仓库 PR 要不要一起验证

虚拟单体仓库最尴尬的场景是:一个需求要同时改公共类库和业务服务,于是两个仓库里各开一个 PR,各自的 CI 都只能看到本仓库代码,无法验证组合后的结果。我们最初是让开发者在描述里互相贴链接,靠人肉确认,后来发现这完全不可靠。

后来我们在控制仓库里做了一个“集成分支覆盖”能力:sync.ps1 支持用额外的overrides.json文件临时覆盖 manifest 中某个仓库的分支,指向某个 PR 分支。CI 里的集成任务会读取两个 PR 的分支,生成临时 override,再执行完整构建。这就是在 CI 层面把原子性还回来。虽然跨仓库仍然没有真正的原子提交,但至少让“组合是否正确”有了一次自动检查。这个能力可以逐步推进,先覆盖核心依赖链,不必一开始就所有仓库都接入。

6. 一点实践心得:虚拟单体仓库不是终局

6.1 同步流程本质上是把“原子性”还回来

这套方案运转一段时间后,我的体会是:虚拟单体仓库并没有科技含量很高的部分,它的核心价值在于让组合状态可复现。lockfile 一旦更新,就代表有一组兼容的提交被完整构建验证过,这成了团队之间默认的“基准”。如果多个团队都无视这个基准,各自在子仓库 main 分支上提交不兼容的变更,虚拟单体仓库很快就会退化成一个依赖矩阵灾难。

所以我们在管理上做了一个约定:所有跨仓库改动,必须在控制仓库留下一次 lockfile 变更记录。这件事不需要强制工具介入,因为它通常自然发生:你改完两个仓库并让 CI 运行成功后,控制仓库的 lockfile 就会更新。真正要盯住的是那些未经组合验证就合入子仓库的提交,这比任何脚本都关键。

6.2 工具要克制:别把同步器做成分布式系统

我们一开始也考虑过做一个服务端编排平台,带 Webhook、数据库、事件流,后来被一句很朴素的话劝住了:核心需求只是给定一组 ref,快速还原一个可构建的工作区。既然如此,一个脚本加一个 lockfile 就够了。过度设计同步器,会把它变成另一个需要维护的分布式系统,而真正的业务问题还在仓库边界上。

如果让我重新做一遍,我会在 manifest 的 schema 上多花点设计时间,例如从一开始就把“分支语义”和“固定提交语义”分开,避免语义混用导致迁移困难。最后的最后,我想强调这不是一篇教你推翻 Git 仓库模型文章,而是在真实约束下找一个能长期运转的中间态。代码仓库的组织方式没有绝对正确,只要团队能理解成本、能守住基准,虚拟单体仓库也可以像一坛老酒一样,越陈越香。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询