▲点击上方“DotNet NB”关注公众号回复“1”获取开发者路线图学习分享丨作者 / 郑 子 铭这是DotNet NB 公众号的第247篇原创文章原文 | Matt Mitchell翻译 | 郑子铭在我写完上一篇关于 .NET 如何构建和发布的文章后我谨慎地乐观地认为我不需要再写一篇了。或者至少不需要再写一篇关于我们如何构建和发布的文章。这个问题已经彻底解决了。.NET 做到了我们在分布式代码库开发和快速构建产品发布的能力之间找到了平衡。恭喜大家现在基础设施团队可以专注于其他事情了。安全、跨公司标准化、支持构建新产品功能等等。所有这些好东西。一年半后……我们正在询问每月发布 3-4 个主要版本每个版本包含十几个 .NET SDK 版本需要多少成本还要保持他们的工程系统更新。另外我们想在下周的版本中加入一个紧急修复所以我今天可以提交代码让团队今晚验证吗应该不难吧还有我想对一个新的跨栈功能进行一些原型设计……我该如何实现呢答案大多令人沮丧“这会花费很多钱而且随着时间的推移情况会越来越糟。 ”“我觉得我们没有足够的时间来修复这个问题我只能估计构建需要多长时间但至少需要36个小时才能交给验证部门。也许更久 ”“我相信我们能够维持如此庞大的基础设施运转但我们会慢慢被不断更新换代的成本压垮。 ”“拥有完整的技术栈对你来说有多重要搭建起来需要一些时间。 ”这些并非我们想要给出的答案。因此我们不得不重新审视问题寻找解决方案。这篇博文主要介绍统一构建项目.NET 通过将产品构建迁移到“虚拟单体”代码库并将构建过程整合为一系列“垂直构建”同时仍然允许贡献者在单体代码库之外工作从而解决诸多问题。我将简要回顾 .NET 生命周期中产品构建的历程。我会重点介绍我们在将分布式产品构建模型应用于单个产品时所吸取的经验教训特别是其在开销和复杂性方面的不足。最后我将深入探讨统一构建及其基础技术——Linux 发行版源代码构建。我们将审视这种新的产品构建方法以及我们所取得的成果。我们怎么会走到这一步这可不是我精心打造的基础设施。.NET 诞生于 2015-2016 年源于 .NET Framework 和 Silverlight 的闭源基础架构。随着我们逐步完善其组件以供外部使用.NET 也逐步开源。当时我们按照惯例将其拆分为多个代码库。CoreCLR 代表基础运行时CoreFX 代表库Core-Setup 代表打包和安装。随后ASP.NET Core 和 EntityFramework Core 以及带有 CLI 的 SDK 也相继问世。在接下来的几个版本中产品进行了重大改进引入了共享框架WindowsDesktop 也加入了其中。代码库更多复杂性也随之增加。需要理解的是.NET 是一个在相互依赖的独立代码库中开发的产品但需要在相对较短的时间内将它们组合起来才能发布。从理论上讲该产品的“依赖关系图”与任何开源生态系统都非常相似。一个代码库生成一些软件组件将其发布到公共注册表中下游用户依赖于这个新组件并发布他们自己的更新。这是一个生产者-消费者模型其中变更通过一系列拉取-构建-发布操作在“全局”依赖关系图中传播。这种模型高度分布式且高效但在时间效率方面未必出色。它使软件供应商和代码库所有者能够对其流程和进度安排拥有相当大的自主权。然而尝试将这种方法应用于像 .NET 这样使用独立但相互依赖的代码库来表示其组件的产品存在重大缺陷。我们不妨称之为“分布式产品构建方法”。为了了解为什么这种方法使用起来比较困难让我们来看看安全版本发布的过程。例如安全服务考虑发布安全补丁。假设在 .NET 运行时库的某个地方发现了一个安全漏洞。由于 .NET 源自 .NET Framework假设该安全漏洞也存在于 .NET Framework 4.7.2 中。那么.NET 的安全更新必须与 .NET Framework 的更新同步发布否则两者之间就会发生零日漏洞冲突这一点至关重要。.NET 拥有众多由微软管理的发布渠道例如 Microsoft Update、我们的 CDN、Linux 和容器包注册表、nuget.org、Visual Studio、Azure Marketplace 等等。这给发布时间带来了一定的限制。我们需要确保发布时间的可预测性。.NET 的开发结构与典型的开源生态系统非常相似。.NET 运行时、.NET SDK、ASP.NET Core 和 Windows Desktop 共享框架由不同的团队开发但彼此之间有着大量的协作。有时它们的开发方式就像是独立的产品。.NET 运行时是产品的基础。ASP.NET Core 和 Windows Desktop 都构建于其之上。大量的开发工具C#、F#、MSBuild都基于 .NET 运行时及其一些辅助库构建。SDK 负责收集和构建 CLI以及任务、目标和工具。许多共享框架和工具的内容都以内置形式重新分发。为了构建和发布这个安全补丁我们需要参与整个 .NET 产品开发的众多团队进行协调。我们需要 .NET 图的底层见下文构建各自的资源然后将其提供给下游用户。他们需要获取更新、构建并向下游提供资源。这个过程会持续进行直到产品达到“一致性”为止此时不再有新的变更被添加到图中并且所有用户都对产品中每个组件的单一版本达成一致。一致性确保所有重新分发组件或其相关信息的地方都能接收到已更改的组件。接下来我们将进行验证从所有未发布组件的闭包中提取所有可发布的资源然后一次性将它们全部发布到全球。这涉及很多环节需要在短时间内协调运作。分布式生态系统的优势和劣势值得注意的是这种分布式生态系统式的开发方式确实有很多优势分层——代码库边界往往鼓励分层并导致产品之间的关联性降低。在主要版本开发生命周期中即使变更在代码图中快速且不均匀地流动堆栈中的各个组件通常也能保持大致兼容。社区——代码库的边界往往有利于形成良好且目标明确的社区。例如WPF 和 Winforms 社区通常是各自独立的。小型代码库通常也更容易上手。增量性——分布式开发通常允许增量式变更。例如我们可以对 System.CommandLine 接口进行重大更改然后随着时间的推移将这些更改逐步引入到各个使用者中。这种方法并非总是有效例如假设 SDK 试图只提供一个 System.Text.Json 副本供所有工具使用但并非所有使用者都同意使用该接口。结果会怎样但它相当可靠。紧凑的内部循环——规模较小、功能集中的代码库往往拥有更好的内部循环体验。即使是像requiregit clone或require这样简单的操作git pull在小型代码库中也会更快。代码库的边界往往会给人一种可能是错觉的错觉即对于你的更改你只需要关注那些你能看到的代码和测试。异步开发——增量式开发有助于实现更异步的开发方式。如果我的组件需要交付给三个位于不同时区的下游用户这些团队可以按照自己的节奏推进各自组件的开发而无需协调配合。低成本分片/增量构建——分布式开发允许“优化”那些不经常更改且位于依赖关系图边缘的组件的构建。例如构建一些静态测试资源的叶子节点无需在每次 SDK 更改时都重新构建。上次构建的资源就足够了。然而如果你仔细分析就会发现分布式模型的诸多优势恰恰是其在构建和发布软件时存在的显著缺陷尤其是在需要短时间内完成对整个图结构中大量部分的变更时。大规模图结构的变更往往缓慢且难以预测。但这是为什么呢难道这种模型本身存在缺陷吗其实不然。在典型的开源软件生态系统例如 NuGet 或 NodeJS 包生态系统中这些方面通常不是问题。这些生态系统并不追求速度或可预测性而是重视每个节点的自主性。每个节点只需关注自身需要生产什么、需要消费什么以及满足这些需求所需的变更即可。然而当我们尝试将分布式模型应用于快速软件发布时往往会遇到困难因为它会加剧两个关键概念的影响我称之为“产品构建复杂性”和“产品构建开销”。这两个概念共同作用导致速度减慢可预测性降低。产品结构复杂性在产品构建的语境下“复杂性”指的是将变更从开发人员的机器上执行到最终以所有所需方式交付给客户所需的步骤数量。我意识到这是一个相当抽象的定义。“步骤”的含义会根据你考察的粒度级别而有所不同。现在让我们专注于概念性的产品构建步骤如下图所示一个简单的多代码库产品构建工作流程。MyLibrary 和 MyApp 由独立的代码库构建。MyApp 部署到两个客户终端。.NET 最初拥有一个相对简单的产品依赖关系图以及相应的管理工具。随着产品规模的扩大新的代码库被添加到图中构建产品也需要额外的依赖流。依赖关系图变得越来越复杂。我们开发了新的工具Maestro我们的依赖流系统来管理它。现在添加新的依赖项比以往任何时候都更加容易。开发人员或团队如果想要为产品添加新功能通常只需创建一个新的代码库然后构建并设置输入和输出即可。他们只需要知道该组件如何融入到更大的产品构建图的一小部分中就可以添加一个新节点。然而.NET 并非独立发布每个单元。产品必须达到“一致性”即所有依赖项的版本都达成一致才能发布。依赖项或其元数据会被重新分发。您必须“访问”所有边。注意虽然我们不需要更新图中的每个组件但每次发布时由于修复或依赖流的变化都会有相当一部分组件发生变化。然后你把每个节点的输出结果全部组合起来就可以出门了。更复杂的图表存在明显的缺点边和节点越多达到一致性所需的时间往往就越长。团队更容易出错。协调环节更多工作流程中人为因素影响结果的环节也更多。工具固然有用但作用有限。复杂性也会导致构建环境和需求出现差异。由于各个团队的迁移和升级速度各不相同很难确保所有人都遵循相同的流程。重建整套环境成本高昂而且随着基础设施的老化成本往往会随着时间的推移而增加。这是 .NET 产品构建流程图中一个虽小但至关重要的子部分大约在 .NET Core 3.1 版本中。Arcade 提供共享的构建基础架构虚线而实线则表示组件依赖关系。变更会在最终到达 SDK 和安装程序之前经过多个代码库的传递。产品结构开销我们将开销定义为“未用于主动生产可交付给客户的产品的耗时”。与复杂度类似开销的评估粒度可以根据需要而定。让我们来看两个简单的例子以及.NET早期版本中的开销。一个简单的多仓库产品构建流程可能如下所示图示为一个简单的多仓库产品构建工作流程中的开销。虚线框内的节点代表开销。在上图中开销节点虚线节点并不直接参与 D 语言包的生成。依赖流服务创建 PR 所需的时间属于开销。等待开发人员注意到并审核 PR 属于开销。等待包推送获得批准也属于开销。这并非意味着这些步骤不必要只是说明在这些环节中我们并没有主动为客户生成输出。构建过程如何如果我们深入分析代码仓库的构建过程通常会发现很多开销。考虑一下这个非常简单的构建过程简单流水线中的开销示意图。虚线框内的节点代表开销。需要注意的是这里有很多步骤并不实际生产或向客户发货。这些步骤或许是必要的但它们仍然是开销。系统中有一些有趣的开销衡量方法。我们可以将其衡量为总时间的百分比。将每个步骤所花费的时间按其分类相加然后将总开销除以总时间。这可以很好地衡量整体资源效率。然而从实际运行时间来看总开销并不能提供太多信息。为了解开销对端到端时间的影响我们首先在产品构建图中找到耗时最长的路径然后计算构成该路径的各个步骤的总开销与该路径总耗时之比。为了了解这种开销在单个 .NET 构建中的具体表现我们来看一下运行时 8.0 的构建。这些数据是使用一个自定义工具生成的该工具可以根据一组对每个步骤进行分类的模式来评估 Azure DevOps 构建。以下是该建筑中最长的三条路径开销 复杂性 时间开销是不可避免的。任何产品构建流程都不可避免地存在一定程度的开销。然而当我们增加产品构建流程的复杂性尤其是图的复杂性时开销往往会占据主导地位并且会成倍增长。例如你可能不是只支付一次机器队列时间成本而是在通过图的单个路径中支付 10 次。分配完这些机器后每次都需要克隆代码库。由于每个步骤都存在固定成本这些步骤的效率扩展性往往也更差。例如如果扫描 10MB 的数据需要 10 秒准备扫描、整理和上传结果需要 1 秒那么连续执行 10 次该步骤所需的时间比一次性扫描全部 100MB 的数据所需的时间更长110 秒对比 101 秒。更棘手的是这种成本往往会隐藏起来并且随着时间的推移而增加。它并不总是显而易见的。对于开发人员来说本地仓库的构建通常很快。开发人员不会感受到整个持续集成 (CI) 系统在该构建过程中产生的任何额外开销。从更宏观的角度来看在流水线作业中构建仓库可能同样很快但开始会产生一些额外开销。虽然仓库本身的构建速度很快但围绕它还有一些额外的开销步骤。尽管如此效率仍然相当不错。然后假设你再放大一些流水线中还有一些额外的作业执行其他操作。例如重用构建其他部分的工件、构建容器等等。这些额外开销在长路径时间中所占的比例就会越来越大。现在再次放大视角你会看到该流水线及其相关仓库在整个产品构建过程中的位置。你还需要加上开发人员 PR 审批的时间、依赖流系统运行的时间、更多的克隆操作、更多的构建操作、更多的合规性检查等等。在分布式产品构建系统中影响复杂性进而影响开销的决策可能在无法感知系统整体开销的层面上做出。例如添加了一个新节点。单独来看这没什么问题。但从整体来看它会带来成本。虽然在 .NET 8 时期从未制作过能够展现每个组件构建在整个产品构建过程中的复杂性的复杂度图但我们可以看看仅运行时构建的作业图是什么样的。下面的每个气泡代表一台独立的机器。.NET 8 构建的复杂性。每个节点代表一台独立的机器。边代表依赖关系。统一构建的根源在于源代码构建.NET Source Build 是一种让 Linux 发行版能够在隔离环境中基于单一统一的源代码布局构建 .NET 的方法。微软大约在 .NET Core 1.1 版本前后开始着手开发这项技术。统一构建的雏形源于 .NET Source Build 团队和负责微软发行版团队之间的一些非正式交流。我承认基础设施团队经常会比较在 Source Build 基础设施中构建 .NET 产品所需的时间这其中难免有些嫉妒。50分钟比在官方 CI 构建中从头开始构建运行时库所需的时间还要短。当然这并非完全公平的比较。毕竟Source Build只构建一个平台。不构建任何仅限 Windows 使用的资源例如 Windows Desktop 共享框架/不构建 .NET 工作负载。不进行任何安装程序打包。默认情况下不构建测试。这些都是非常合理的注意事项。但这些注意事项加起来足以造成数十小时的构建时间差异吗不太可能。更有可能的是源代码构建方法复杂度低、开销小。除了时间优势之外它还有其他显而易见的优点统一的工具集、更便捷的跨栈开发以及或许最为重要的——对构建内容及其构建时依赖关系的明确保证。回到走廊里的那些对话。Source Build 的显而易见的优势引发了 .NET 团队成员时不时提出的探究性问题。大多数问题都是这样的那么……为什么微软不以这种方式构建其发行版呢答案是这很难。原文链接Reinventing how .NET Builds and Ships (Again)推荐阅读【译】 数据摄取构建模块简介预览版(二)【译】 数据摄取构建模块简介预览版(一)【译】 如何使用 .NET MAUI 构建 iOS 小部件【译】 Microsoft.Testing.Platform 现已在 Azure DevOps 中得到全面支持【译】 安全智能MongoDB EF Core 提供程序中的可查询加密和向量搜索【译】 我们如何同步 .NET 的虚拟单体仓库二点击下方卡片关注DotNet NB一起交流学习▲点击上方卡片关注DotNet NB一起交流学习请在公众号后台回复【路线图】获取.NET 2024开发者路线回复【原创内容】获取公众号原创内容回复【峰会视频】获取.NET Conf大会视频回复【个人简介】获取作者个人简介回复【年终总结】获取作者年终回顾回复【加群】加入DotNet NB 交流学习群长按识别下方二维码或点击阅读原文。和我一起交流学习分享心得。