【译】 我们如何同步 .NET 的虚拟单体仓库(一)

张开发
2026/5/11 2:44:24 15 分钟阅读

分享文章

【译】 我们如何同步 .NET 的虚拟单体仓库(一)
▲点击上方“DotNet NB”关注公众号回复“1”获取开发者路线图学习分享丨作者 / 郑 子 铭这是DotNet NB 公众号的第240篇原创文章原文 | Přemek Vysoký翻译 | 郑子铭在之前的文章“重塑 .NET 构建和发布方式”中Matt 介绍了我们近期对 .NET 构建和发布流程的全面改进。这项历时数年的工作我们称之为“统一构建”其关键部分在于引入了虚拟单体仓库 (VMR)它聚合了构建 .NET SDK 所需的所有源代码和基础架构。本文将重点介绍单体仓库本身它的创建过程以及维持其运行的双向同步的技术细节。直到最近.NET SDK 都是由数十个代码库的构建产物聚合而成的。这些产物沿着代码库树向下流动最终拼接在一起生成最终的 .NET SDK。这种方法多年来一直行之有效但也带来了显著的复杂性和维护开销。自.NET 10 Preview 4起我们开始使用单体代码库的单个提交来构建 .NET SDK。什么是虚拟单体存储库虚拟单体仓库 (VMR) 是一个单一的 Git 仓库其中包含构建 .NET SDK 所需的所有源代码和基础架构。您可以在 GitHub 上的dotnet/dotnet目录下找到 VMR。实际上它主要由数十个其他独立仓库例如dotnet/runtime或dotnet/sdk 聚合而成我们称这些仓库为“产品仓库”。此外它还包含构建基础架构、管道定义和脚本等其他资源。产品仓库仍然独立存在并以子目录的形式与虚拟仓库 (VMR) 中的对应仓库同步。这就是虚拟仓库的由来。更改既可以在产品仓库中进行也可以直接在虚拟仓库中进行。我们的基础架构通过创建拉取请求来保持这两者之间的同步这些拉取请求会将源更改传递到这两部分。通往VMR之路拥有双向同步的单体仓库一直是统一构建项目的必要基石。然而实现这一点并非一蹴而就而在此过程中我们必须持续发布产品。第一阶段 – 源代码构建压缩包这段旅程始于 .NET 6 时期当时我们投入大量资源致力于让 .NET 能够在各种 Linux 发行版例如 Ubuntu、Fedora、Debian以及 Homebrew 等软件包管理器中可用。为了实现这一目标我们必须遵守这些发行版维护者的规则。这些规则通常可以归纳为所有内容均需提供源代码不允许提供二进制文件。网络访问受限或无网络访问换句话说我们必须能够向维护人员提供一组非二进制源文件这些文件无需从互联网下载任何内容即可编译成 .NET SDK。我们将此过程称为源代码构建。源代码构建方法与我们过去构建 .NET SDK 的方式有所不同。在 VMR 之前我们构建 .NET SDK 的方式是构建产物通过数十个代码库的依赖关系树逐步流出。两种方法都存在相同的需求——依赖关系流必须到达最终代码库最初是repositorydotnet/installer后来是dotnet/sdkrepository。然后您可以收集二进制文件或这些二进制文件背后的源代码并将它们提供给最终构建。Source Build 的第一个版本会遍历代码树中每个仓库的提交记录添加 Source Build 的基础架构即 Source Build 背后的逻辑并动态生成一个 tarball 归档文件。然后该归档文件会被提供给第三方维护者他们在自己的系统上构建该文件并将生成的软件包提交到各自的软件包仓库中。源代码构建补丁我们经常发现收集到的源代码无法成功构建。构建方法各不相同而且在产品依赖关系流程完成之前发现问题往往过于复杂且成本高昂。有时这甚至会在产品发布前暴露出已存在的集成问题。一旦源代码构建失败就需要在某个产品代码库中进行修复然后再次向下传播到依赖关系树。这是一个繁琐、耗时、成本高昂且容易出错的过程。为了缓解这个问题我们允许将所谓的“源构建补丁”提交到最后一个代码库。这些包含修复的额外补丁会应用到已收集的源文件之上。然后我们会将补丁合并到上游原始代码库即已打补丁的源文件所在的代码库。一旦修复后的源文件再次向下传递就可以移除该补丁——因为此时已收集的源文件已经包含了这些更改所以该补丁将无法再次应用到它们之上。第二阶段 – VMR-lite为了迈出实现完整 VMR 代码流程的第一步我们需要放弃基于 tarball 的方法转而使用专用的 Git 仓库。仓库内容与 tarball 相同但迁移到 Git 意味着需要投入代码和变更管理方面的资源而这些对于最终实现统一构建 VMR 至关重要。2022 年 10 月我们创建了最初的dotnet/dotnet仓库。它的代号是“ VMR-lite ”是产品仓库源代码的只读镜像投影。每次我们将提交合并到 SDK 代码库时都会触发一个单向同步管道。它会遍历依赖关系树收集所有依赖项背后的提交并更新 VMR 中相应的子目录。简化的图表显示了从产品存储库到 VMR-lite 的单向同步过程。在此过程中会排除源代码构建规则禁止的二进制文件等不需要的文件。源代码构建补丁也会被应用。linux-x64从.NET 8 Preview 1开始 VMR-lite 成为 Linux 发行版源代码构建的发布载体并且至今仍用于 .NET 8 和 .NET 9 的服务。第三阶段 – 可写虚拟机将源代码构建开发流程迁移到 VMR 是一个重要的里程碑极大地改进了工作流程。借助 VMR 的提交历史记录分析源代码构建失败变得更加容易。当我们把 VMR 集成到合规性和安全扫描基础设施中时其他优势也显现出来。然而我们的目标远不止于此。最终目标是统一我们面向二进制文件和基于源代码的构建方法并将 VMR 作为我们所有 .NET SDK 构建的开发和发布平台。图中还缺少两个关键部分。首先我们必须使虚拟仓库VMR可写。这也意味着需要能够将变更反馈到产品仓库。其次源代码不再通过依赖树的顶端传入。相反每个产品仓库都将以“扁平化”代码流模型直接与虚拟仓库同步。存储库和 VMR 之间扁平代码流结构的示意图。另一个显著区别在于整个流程的实现方式。在 VMR-lite 中我们在 Azure DevOps 管道中编译了新的源代码集并直接将其推送到 VMR。现在我们的依赖关系流云服务通过计算差异并创建包含更改的拉取请求来驱动整个流程。这还允许我们在合并更改之前运行拉取请求验证门并在拉取请求中进行额外的修复。由于虚拟仓库 (VMR) 可写我们可以轻松地在特定仓库中引入破坏性变更将变更流到 VMR 的 PR 中并在同一个 PR 中修复其他仓库的依赖代码。依赖仓库目录的变更随后会回流到它们各自的原始仓库。回流过程中还会包含使用上述破坏性变更构建的 VMR 二进制文件这些二进制文件是其他仓库构建所依据的。从依赖树到扁平化流程的过渡发生在.NET 10 Preview 5的发布中双向同步的完整 VMR 也从那时开始运行。VMR的存储模型首先要做的决定是确定如何构建和创建虚拟主资源库VMR本身。我们需要考虑多方面的需求这些需求既包括我们满足源代码构建需求的能力也包括我们未来双向同步的计划拥有一个单一的、连贯的提交能够随时捕获一致且可构建的状态。能够应用源构建补丁永久增量。为了能够映射其他路径——将源投影到 VMR 的其他部分例如根目录的内容。能够排除某些路径/文件——例如排除某些 Linux 发行版禁止的二进制文件。以便能够对 VMR 进行更改使更改后的版本能够同步到产品存储库中。我们探索了将多个存储库聚合为一个存储库的几种方法Git 子模块Git 子树子仓库定制流程经过仔细调查我们发现这些方案都无法完全满足我们的要求因此我们决定实施我们自己的自定义流程将文件维护在一个单一的单仓库中作为原始源代码的检入副本。关于这一决定的更多细节请参阅原始设计文档。处理子模块我们的一些产品代码库已经包含Git 子模块并且需要这些子模块才能成功构建。其中一些甚至位于 .NET 基金会之外。从技术上讲这些子模块也可以保留在 VMR 中。然而这与 VMR 的一些要求和目标相冲突任何给定的提交都包含构建 .NET SDK 所需的所有源代码。Source Build 要求构建过程必须在没有互联网连接的情况下进行以确保在此过程中不会下载任何其他工件。Source Build 禁止在 VMR 中使用非文本文件。作为优秀的开源公民我们希望尽可能多地将更改汇回子模块。为了满足 .NET 服务需求我们希望避免长期依赖外部子模块的远程存在。上述限制给我们提供了两种选择我们可以创建所有子模块的分支移除所有非文本文件并将这些分支作为子模块引用到虚拟主仓库VMR中。然后我们需要保持这些分支与上游同步。我们将子模块作为源代码的硬拷贝引入 VMR而不是将其保存为子模块链接在此过程中会剥离二进制文件。我们权衡了两种方案最终决定采用后者这意味着与上游协作时阻力更小。无需中间人修改我们可以更快地使用新版本并确保更轻松地向上游做出贡献。同时我们始终可以访问所有源代码而且使用 VMR 也避免了子模块使用带来的种种复杂性。移动变化同步过程的核心在于能够高效地在不同存储库之间移动文件或者更准确地说是移动文件的变更。需要注意的是变更的形式多种多样。除了显而易见的添加/删除和内容修改之外文件的权限可执行位也可能被修改编码也可能发生变化甚至整个文件都可能被移动。在设计过程的早期我们就意识到如果我们不想自己实现所有这些操作的细节就需要尽可能多地将工作委托给 Git。这意味着在代码仓库之间迁移变更的主要方式将是传统的补丁主要原因如下补丁程序完整地编码了文件可能发生的所有不同类型的更改。补丁可以应用于不同的路径例如根目录的映射。创建补丁时很容易排除/包含某些文件或模式这使我们能够过滤掉不需要的文件例如二进制文件。当出现意外内容时补丁应用程序会失败这可以确保流程的正确性并防止意外覆盖。为了说明这在实践中是如何运作的我们只需调用git diff --patch--binary--relative -- ex/inclusion patterns然后我们将生成的补丁应用到目标路径中。git apply --cached --ignore-space-change --directorytarget dir如前所述我们有意简化此流程将与文件变更相关的繁重工作尽可能交给 Git 本身处理。事实证明除了少数需要特殊处理的特殊情况外这一决定相当稳健。例如git applyGit 将补丁的最大大小限制在 1 GB 以下。为了规避此限制我们会检测到该限制并将补丁递归地分割成更小的块。追踪源头由于同步过程显然会涉及补丁下一步就是弄清楚如何跟踪哪些源已同步到哪些位置。为了跟踪 VMR 内部的内容我们维护了一个清单文件。该文件包含当前在该 VMR 提交中同步的所有产品仓库的提交 SHA 值。此外它还记录了 vendored 子模块的 SHA 值。类似地我们也跟踪产品仓库中最后一次同步的 VMR 提交用于双向同步。为此我们将最后一次同步的 VMR 提交的 SHA 值保存在我们已用于跟踪仓库依赖项的Version.Details.xml文件中。通过 git blame 分析跟踪数据我们可以确定对方仓库的哪个提交在何时同步到了当前仓库。这足以计算出代码在双方仓库之间随时间推移的流动情况。我们将在算法的后续部分中利用这些信息来确定最后几条代码流及其方向。其原理和具体操作将在后续章节中详细阐述。到目前为止这个决定对我们来说效果不错但也存在一些挑战。例如当一个仓库决定合并其分支时可能会意外覆盖跟踪数据从而导致错误。此外由于更改跟踪数据会影响git blame的结果因此在需要时“重置”跟踪数据也可能比较困难。我们目前正在探索更稳健的方法例如使用git notes将数据存储在主源代码树之外。单向同步如前所述VMR-lite 是产品仓库的只读版本。部分内容例如被我们的源代码构建合作伙伴拒绝的二进制文件被排除在同步之外。其他内容可以映射到不同的路径例如来自dotnet/installer 的根目录。最后源代码构建补丁会应用到已同步的内容之上。为了配置这些同步规则VMR 中包含一个配置文件其内容大致如下{ // Each mapping represents a product repository with its own content-exclusion rules mappings:[ { name:runtime, defaultRemote:https://github.com/dotnet/runtime, exclude:[ tests/**/*.dll ] }, { name:aspnetcore, defaultRemote:https://github.com/dotnet/aspnetcore, }, // ... ], // Example of additional mapping of content to the root of the VMR additionalMappings:[ { source:src/SourceBuild/content, destination:/ } ], // Path to the directory containing Source Build patches patchesPath:src/installer/src/SourceBuild/patches }同步过程本身不仅是 VMR-lite 的基本构建模块也是后续完整双向同步的基础。由于该过程必须处理子模块的变更以及源代码构建补丁因此其复杂性在于此外定义同步规则的配置文件也可能发生变化。这意味着源代码构建补丁和额外映射的内容都可能在同步过程中发生更改需要正确地移除这些更改然后再重新应用。根据我们目前所了解的一切我们可以将整个过程总结为以下步骤撤销 VMR 中应用的所有源构建补丁。确定代表代码仓库树的提交集合。对于每个需要更新的存储库还原来自此存储库的额外映射内容。在原始仓库中于上次同步提交和新提交之间创建一个补丁提交范围等于先前同步的提交来自清单文件和当前同步的提交。遵守排除规则。忽略子模块的更改。如果补丁太大则将其分割。将补丁应用到 VMR 中的仓库子目录。检查仓库的子模块按照上述相同模式递归地为每个子模块的更改创建补丁。将这些补丁应用到 VMR 中的相应子模块目录。应用来自此存储库的额外映射内容的更改。我们再次为给定的路径和提交范围创建并应用补丁。更新清单文件中的跟踪信息。将源构建补丁应用到同步内容之上。很好现在我们可以将产品存储库中的更改移动到 VMR 中了。双向同步这项工作的最终目标是实现虚拟仓库 (VMR) 和产品仓库之间的双向同步。如前所述这将不再通过流水线直接将变更推送到 VMR 来实现。取而代之的是我们的代码流服务将创建所谓的“代码流拉取请求”来承载这些变更。在创建拉取请求分支时我们将继续使用我们首选的变更推送工具——补丁。尽管流程的基本组成部分保持不变但整个问题却变得复杂得多。除了确保正确的变更以正确的方式在另一端实现之外我们还必须考虑并行发生的、且频率往往不同的流程。换句话说变更可能以每日的节奏持续向一个方向流动而另一个方向的拉取请求却可能由于集成构建中断而被阻塞需要数天甚至数周才能合并。代码流算法必须能够理解这些情况并确保仅在实际发生冲突变更时才显示冲突。正确固然重要但并非一切。拉取请求还必须清晰地传达所包含的变更、变更来源并在需要解决冲突时提供指导。每月在数十个代码库之间流动数百万行代码很容易导致混乱因此开发人员必须配备合适的工具和信息才能做出正确的决策并掌控全局。最后我们还需要了解系统的整体状态以便识别流程中断、长期存在的拉取请求以及其他潜在的瓶颈。开发人员的体验和可观测性对于我们维护健康的系统至关重要。与之前类似我们开发了一种自定义算法并对其进行迭代。我们也会详细介绍算法的演变过程以便更好地说明我们的经验。希望这些经验能对任何尝试解决类似问题的人有所帮助。原文链接How We Synchronize .NET’s Virtual Monorepo推荐阅读【译】 如何使用 .NET MAUI 构建 Android 小部件【译】 GitHub Copilot Testing for .NET 将 AI 驱动的单元测试引入 Visual Studio 2026Maomi.MQ 功能强大的 .NET RabbitMQ 消息队列通讯模型框架来了推荐一个开源的 .NET 工作流引擎和审批流项目推荐一个基于 .NET 10 开源的 RBAC权限体系的通用后台管理系统推荐一个基于 .NET 开发的功能强大的权限可视化流程管理系统点击下方卡片关注DotNet NB一起交流学习▲点击上方卡片关注DotNet NB一起交流学习请在公众号后台回复【路线图】获取.NET 2024开发者路线回复【原创内容】获取公众号原创内容回复【峰会视频】获取.NET Conf大会视频回复【个人简介】获取作者个人简介回复【年终总结】获取作者年终回顾回复【加群】加入DotNet NB 交流学习群长按识别下方二维码或点击阅读原文。和我一起交流学习分享心得。

更多文章