原文 | Matt Mitchell
翻译 | 郑子铭
为什么这么难?让我们绕道进入 Source Build 的世界。
微软大约在 .NET 3.1 版本发布前后开始着手将 Source Build 打造成一个“真正”的机制。在此之前,Source Build 的发布更像是针对每个 .NET 主要版本进行的一次性工作。由于持续开发过于困难,团队从春季新产品成型之初就开始着手,使新版 .NET 符合 Linux 发行版维护者的要求。为了理解为什么将微软的 .NET 发行版融入统一构建 (Unified Build) 项目如此困难,让我们回顾一下 Source Build 项目最初为何难以步入正轨。
为了使我们的发行合作伙伴能够分发 .NET,我们需要构建一个基础架构系统,该系统能够在以下限制条件下生成 .NET SDK:
单一实现!——每个组件只能实现一次
单平台——仅针对一个平台构建(即发行商合作伙伴试图发布的平台)。
单机构建——只需在一台机器上构建。我们不能要求复杂的编排基础架构。
Linux 发行版构建要求
Linux 发行版在构建将发布到其软件包源中的软件时,通常有更严格的规则和更低的灵活性。构建通常在离线状态下完成(断开与互联网的连接)。它只能使用之前在该构建系统中创建的工件作为输入。不允许使用已提交的二进制文件(尽管可以在构建时将其移除)。仓库中的任何源代码都必须满足严格的许可要求。有关 .NET 许可的信息,请参阅许可批准;有关示例发行版要求,请参阅 Fedora 许可批准。从概念层面来说,Linux 发行版合作伙伴希望能够追踪他们发布的每个工件,并将它们追溯到一组他们可以合理编辑的源代码和流程。所有未来的软件都应该基于之前 Source Build 生成的工件进行构建。注意:正如您所料,可能需要一个引导过程。
单一构建——一个用于将整个技术栈连接起来的仓库和编排框架
正如您之前了解到的,.NET 构建(与许多产品一样)实际上是由各种组件的 Azure DevOps 构建版本组成,并通过依赖项更新将它们拼接在一起。这意味着构建产品所需的信息和机制分布在存储库(构建系统中的构建逻辑和相关脚本,以及 Azure DevOps 处理的 YAML 文件)和我们“Maestro”系统保存的依赖关系流信息(生产者-消费者信息)之间。这对于我们的 Linux 发行版合作伙伴来说是不可行的。他们需要在无法访问这些 Microsoft 资源的情况下构建产品,并且构建方式必须适合他们的环境。手动从构建图中拼接产品是不合理的。我们需要一个能够封装这些信息的协调器。
源构建布局和协调器
编排器将 Azure DevOps 和 Maestro 为 .NET 分布式构建执行的任务替换为可以从单一源布局运行的任务,而无需连接互联网。您可以在dotnet/dotnet上查看现代化的更新布局和编排器。
单源布局–包含构建产品所需所有组件副本的单源布局。如果存在子模块(通常用于外部开源组件),则会将其扁平化。源布局的内容由产品图中每个组件的带注释依赖项决定,该依赖项以dotnet/sdk为根。该带注释依赖项的 SHA 值决定了布局中将填充哪些内容。注意:编译器和操作系统库等依赖项由构建环境提供。
关于每个组件的构建方式及其依赖关系的信息——对于单源布局中的每个组件,都提供了一个基本项目,其中说明了组件的构建方式。此外,还指明了组件级别的依赖关系。例如,必须先构建 .NET 运行时,ASP.NET Core 才能启动。
[code][/code]
构建协调器逻辑——构建协调器逻辑负责在构建图中的每个构建准备就绪(所有依赖项均已成功构建)时启动构建,并负责每个组件的输入和输出。组件构建完成后,协调器负责识别输出并为下游组件构建准备输入。您可以将其视为本地的 Dependabot,它计算已声明的输入存储库与包级依赖项信息的交集(例如,请参阅aspnetcore 的相关文档)。有关 .NET 构建中依赖项跟踪工作原理的更多信息,请参阅我之前的博客文章。
合规性验证——由于我们的 Linux 发行版合作伙伴构建的环境相对严格,因此我们需要构建一些自动化流程来识别潜在问题。该协调器可以识别预构建的二进制输入、“毒化”泄漏(先前从源代码构建的资源出现在当前构建输出中)以及其他可能阻碍合作伙伴的风险。
冒烟测试——我们的大部分测试逻辑仍然保留在各个存储库中(稍后会详细介绍),但布局中也包括冒烟测试。
单一部署方案 – 预构建且简洁明了
使用“标准”的 Microsoft .NET 版本难以满足这些要求,以及 Source Build 需要如此多的工作,这其中既有一些显而易见的原因,也有一些不易察觉的原因。构建一个包含预先准备、已识别且可从源代码构建的输入的离线版本是一项艰巨的任务。当 Source Build 团队开始研究这意味着什么时,他们很快发现 .NET 产品构建中隐藏着许多有趣的行为。当然,像优化数据这样的二进制输入显然是不允许的,但其他一些基础资产,例如 .NET Framework 和 .NET Standard 目标包,也无法从源代码构建。它们要么一开始就不是开源的,要么已经多年没有构建过了。更令人担忧的是,.NET 的图结构特性意味着不一致非常普遍。其中一些不一致是不理想的(我们在产品构建过程中会努力消除的那种)。而另一些则是预期之内的,甚至是我们所需要的。
示例:Microsoft.CodeAnalysis.CSharp
例如,我们来看一下构建在dotnet/roslyn代码库中的 C# 编译器分析器。这些分析器会Microsoft.CodeAnalysis.CSharp根据所需的支持范围引用不同版本的软件包,以确保发布的分析器能够运行所有需要支持的 Visual Studio 和 .NET SDK 版本。它们引用的是尽可能低的版本。这样可以确保分析器能够以可持续的方式得到维护,而无需为每种可能的 VS 或 SDK 配置都发布一个不同版本的分析器。
由于引用了多个表面积版本,因此在构建过程中会恢复多个版本。这意味着,就源代码构建而言,我们需要在某个阶段Microsoft.CodeAnalysis.CSharp构建所有这些版本。我们有两种方法可以做到这一点:Microsoft.CodeAnalysis.CSharp
多版本源代码布局——将 dotnet/roslyn 的多个副本放入共享源代码布局中,每个副本对应一个引用Microsoft.CodeAnalysis.CSharp版本,并根据其最初发布时间进行排序。这不仅会显著增加构建时间,而且容易导致问题蔓延。如果您需要构建 3 个版本的 dotnet/roslyn,则必须确保这 3 个版本的传递依赖项也存在于共享布局中。这种设置的维护复杂性会迅速增加。这些是以前发布的 dotnet/roslyn 源代码库版本。随着时间的推移,必须维护这些代码库的安全性和合规性,例如升级构建时依赖项、移除已停止维护 (EOL) 的基础架构等等。
要求提供之前源代码构建的版本——这实际上只是多版本源代码布局的一种变体,带有“缓存”的元素。如果发行版维护者需要从头开始重新构建产品,或者正在引导启动一个新的 Linux 发行版,他们可能需要重新构建 .NET 过去版本的大部分内容,才能使最新版本以兼容的方式构建。如果这些旧版本需要进行更改才能以兼容的方式构建,那么维护工作又会变得非常棘手。
源代码构建参考包
还有许多其他例子,例如 Microsoft.CodeAnalysis.CSharp。每当项目面向较低版本的框架(例如在 .NET 10 版本中面向 .NET 9)时,都会恢复较低版本的引用包。SDK 工具(编译器、MSBuild)面向的常用 .NET 包版本与 Visual Studio 自带的版本相匹配。那么我们该如何解决这个问题呢?我们不能简单地在不从根本上改变产品的情况下,统一产品中引用的所有组件的版本。
Source Build 团队意识到,很多这样的用法都非常适合归类为“仅引用”包。
当项目针对与 SDK 主版本不匹配的 TFM 进行构建时(例如,使用 net10 SDK 针对 net9),SDK 恢复的目标包不包含实现。
对旧版本的引用仅Microsoft.CodeAnalysis.CSharp涉及表面信息,不会重新分发这些软件包中的任何资源。如果不需要实现,可以使用仅引用软件包进行替换。
引入dotnet/source-build-reference-packages。仅供参考的包创建和构建起来要简单得多,而且能够满足构建过程中使用者的需求。我们可以为不需要实现的包生成参考包源代码,然后创建一个基础架构来存储、构建这些源代码,并在源代码构建过程中提供它们。提供多个版本也相对容易。dotnet/source-build-reference-packages 存储库在 .NET 构建期间构建,然后使用该存储库的组件会还原并基于提供的参考接口进行编译。
那么那些非参考案例呢?
有了引用包的解决方案,我们就可以将注意力转向其他不符合源代码构建规范且不属于“引用”类别的输入。这些输入主要分为三类:
闭源或无法从源代码构建的输入——优化数据、Visual Studio 集成包、内部基础架构依赖项等。
遗留系统 – 对旧版本 .NET 中内置实现的开源依赖项。
连接 – 对构建在其他平台上的实现的开源依赖项。
让我们来看看我们是如何处理这些案件的。
闭源/非开源可构建输入
Linux 发行版维护者构建版本中绝对不允许使用闭源或任何无法从源代码构建的输入。为了解决这些问题,我们会分析每种使用情况以确定解决方案。请记住,我们的目标是为发行版合作伙伴提供符合规范的构建实现,使其功能尽可能接近微软提供的版本。也就是说,我们不希望微软的 Linux x64 SDK与 Red Hat 的 Linux x64 SDK 的行为存在显著差异。这意味着 Linux x64 的运行时和 SDK 布局需要尽可能保持一致。好消息是,很多闭源的使用并非生成功能等效资源的必要条件。例如:
我们可能会恢复一个启用签名功能的软件包,这在发行版合作伙伴构建版本中并非必需。
dotnet/roslyn 代码库构建了支持 Visual Studio 的组件。这些组件依赖于定义 IDE 集成范围的 Visual Studio 包。然而,这种 IDE 集成并未包含在 .NET SDK 中。可以通过调整构建方式在源代码构建中“移除”此功能。这相当常见。
如果无法在不改变产品功能的情况下减少依赖项,我们还有一些其他的选择:
开源依赖项——很多时候,闭源组件,或者至少满足特定场景所需的闭源组件的关键部分,都可以开源。
改变产品行为——有时,团队可以通过有意的设计变更来消除产品差异。请记住,关键在于所有通过发行版合作伙伴软件包源发布的内容都必须从源代码构建。这样就可以动态地引入一些资源。可以将其理解为 NPM 软件包生态系统与 NPM 软件包管理器之间的区别。发行版可能会从源代码构建 NPM 软件包管理器。这样,用户就可以在构建时动态地恢复 NPM 软件包。
略有不同的行为——这种情况非常少见。在 .NET 10 之前,尽管 WinForms 和 WPF 项目模板以及 WindowsDesktop 已包含在微软的 Linux 发行版中,但它们并未包含在源代码构建的 Linux SDK 中。这是因为在非 Windows 平台上构建这些存储库所需的组件非常困难。
遗留依赖项
我们已经讨论过如何处理闭源和不可复现的依赖项。那么,遗留依赖项呢?首先,我们所说的“遗留”依赖项是什么意思?正如之前的讨论中所述,产品中存在相当多的“不一致性”。一个项目可能需要构建到多个目标框架,并重新分发来自旧版本 .NET 的资源。这一切都是为了支持重要的客户场景。但是,构建所有这些组件的所有版本是不切实际的。这就是我们单一实现规则发挥作用的地方。我们为每个组件选择一个版本进行构建,并将其随产品一起发布。我们允许通过 dotnet/source-build-reference-packages引用旧版本,但禁止依赖旧的实现。
首先,我们寻找避免依赖关系的方法。我们正在开发的 Linux SDK 是否需要这种依赖关系?如果不需要,我们可以从构建过程中移除该代码路径。如果需要,能否统一使用单一实现?很多情况下,不一致仅仅是由于产品组件推进其依赖项的速度不同造成的。如果所有方法都行不通,我们可以考虑一些涉及行为差异的折衷方案,但我们希望尽可能避免这种情况。
连接和垂直性
连接是最后需要移除的预构建项的主要类别。它们的出现是因为产品内部存在依赖关系,而这些依赖关系是在其他环境中构建的。例如,我可能在 Windows 上运行一个构建,该构建会为一个全局工具创建一个 NuGet 包,但要构建该 NuGet 包,我需要 Mac、Linux 和 Windows 的原生 shim 可执行文件。这些 shim 只能(合理地)在 Mac 和 Linux 主机环境中构建。这类依赖关系表明产品构建更倾向于“编织式”而非“垂直式”,并且往往会在多仓库产品构建图中随着时间的推移自然出现。该图中的每条边都代表一个序列点,在该点上,之前所有节点的输出都可用,无论它们是在哪里构建的。如果可以获取某个依赖项,就会获取它。
然而,为了满足发行版合作伙伴的要求,他们的构建必须是单平台、单调用的。撇开引导启动不谈,他们希望拉取依赖项,断开机器与网络的连接,然后点击构建。最终,一个崭新的 .NET SDK 就此诞生。跨平台依赖项会阻止这种行为,它们阻碍了“构建垂直性”。请记住连接(join)。稍后,当我们开始基于源代码构建模型为 Microsoft 实现统一构建时,我们将再次讨论连接问题。
对于源代码构建,我们同样需要处理类似传统依赖项的连接问题。需要记住的关键一点是,源代码构建专注于在 Linux 发行版合作伙伴的构建环境中生成 .NET SDK 和相关的运行时环境。因此,我们会尽可能消除依赖项(例如,在 Linux 上运行 SDK 时,我们不需要打包 Windows 全局工具可执行存根),并根据需要重新设计产品或产品构建流程以满足需求(例如,.NET 工作负载清单)。
愿景——构想统一的构建
统一构建旨在将我们 Linux 发行版合作伙伴 Source Build 的通用原则应用于微软发布的产品。实现这一目标将为 Linux 发行版合作伙伴、上游贡献者和微软带来巨大收益,降低维护成本,并提高构建和发布速度。尽管我们从一开始就知道,如果不进行重大产品改动,我们可能无法完全复制 Linux 发行版的构建方法,但我们认为可以尽可能接近。.NET 提出了以下几个高级目标(注意,“.NET 发行版维护者”指的是所有参与 .NET 构建的人员,包括微软):
单个 Git 提交代表特定 .NET 版本的所有产品源代码。所有提交都是一致的。
一次仓库提交即可生成一个可发布的版本
.NET 的构建应该能够在单个构建环境中创建特定平台的发行版。
.NET 发行版维护者应该能够在 .NET 版本的整个生命周期(从第一次提交到最后一次提交)中高效地更新和构建 .NET(无论是协作还是单独)。
.NET 发行版维护者无需使用微软提供的服务即可生成下游发行版。
.NET 发行版维护者应能够满足其发行版的出处和构建环境要求。
.NET 发行版维护者应该能够协调下游发行版的补丁程序。
.NET 发行版维护人员可以对构建的产品运行验证测试。
.NET 贡献者应该能够轻松地生成完整的产品版本,用于测试、实验等。
.NET 贡献者应该能够高效地完成他们负责的产品部分的工作。
然而,要实现这一点,还需要解决大量新问题。让我们来看看在使用 Source Build 作为微软的 .NET 构建工具之前,我们需要解决哪些问题。
提供一种方法来确定哪些成分最终会进入产品中。
使用分布式模型构建产品时,产品的构建、验证以及最终产品构成要素的确定都紧密相连。源代码构建基于最终统一的图结构,并采用扁平化的源代码布局。然而,它依赖于传统的 .NET 产品构建流程来确定布局中每个组件的版本。为了充分发挥分布式模型的优势,我们需要一种方法,可以直接在共享源代码库中更新组件,而无需复杂的依赖关系。否则,如果开发人员想要在运行时进行更改,他们最终需要构建两次产品:一次是将更改应用到运行时构建的所有路径,另一次是使用新的运行时构建产品。
我们拥有的
高亮显示的路径展示了运行时更改如何在分布式构建模型中通过多个存储库级联,需要顺序构建和依赖关系流更新。
我们需要什么
高亮显示的路径展示了运行时更改如何立即反映到源布局中。我们称之为“扁平化流程”。
提供一种应对突发性变化的方法
扁平化依赖流显著减少了跳转次数,从而降低了变更进入共享源代码布局过程的复杂性和开销。我们可以看到,在变更最终进入产品之前,它仍然需要经过 PR 验证,甚至可能还要经过更深入的滚动 CI 验证。然而,假设这项变更需要组件的响应。尽管依赖流已更改为扁平化依赖流,但 ASP.NET Core 仍然依赖于 .NET 运行时。而布局中的 ASP.NET Core 代码并不知道运行时环境的变更。因此,在变更被允许进入共享源代码布局之前,我们所做的任何 PR 验证都注定会失败。
在传统的依赖关系流系统中,我们通过在依赖项更新 PR 中进行更改来处理这种情况。如果 API 发生更改,构建就会失败。开发人员(理想情况下)在 PR 中进行更改,验证通过后,PR 即可合并。为了使单源方法适用于 .NET,我们需要能够在 dotnet/runtime 更新 PR 中更改其他组件的源代码。
提供一种针对存储库基础架构进行验证的方法
正如我们之前讨论的,大量关键验证代码都位于组件仓库层级。开发人员的大部分时间都花在那里。移动或复制所有这些代码可能既浪费资源,成本也肯定很高,而且维护起来也可能很困难。如果我们无法依靠依赖流在组件流入共享源布局之前进行验证,那么我们就需要一种方法在之后进行验证。
为了解决这个问题,我们可以让新产品构建的所有输出都回流到各个代码仓库,并与它们Version.Details.xml文件中的依赖项进行匹配。这意味着 dotnet/aspnetcore 将获得大量新的 .NET 运行时包,dotnet/sdk 将获得大量新构建的 ASP.NET Core、.NET 运行时和 Roslyn 编译器包,等等。它们将根据代码仓库的基础架构验证其输入依赖项的“最新构建”版本。
Backflow 提供了一种方法来验证最近构建的 .NET 输出是否符合存储库基础架构。
提供双向代码流
假设一个运行时插入 PR 更改了某个 API 的签名System.Text.Json。当该更改向前传递时,负责的开发人员会更新所有下游用户的签名。假设这涉及 src/aspnetcore/和 中的代码src/windowsdesktop/ 。新产品构建完成后,包含新 API 签名的更新后的 System.Text.Json 包会返回到 dotnet/aspnetcore和 dotnet/windowsdesktop。 的 HEADmain并没有包含直接在共享布局向前传递的 PR 中所做的源代码更改。开发人员需要将这些更改移植过来,并在回溯 PR 中进行相应的修改。这既繁琐又容易出错。我们的新系统需要提供一种方法,将共享布局中所做的更改自动传递回源代码库。
组件变更会同步到我们的共享源代码布局,而仅在共享源代码布局中所做的其他变更则会同步回组件仓库及其支持包。请注意,这是将共享源代码变更同步回溯的通用功能,而不仅仅是同步正向提交的 PR 中的变更。
提供更好的插入时间验证
反向流验证并非完美无缺。它无法为依赖组件中的错误变更提供简便的合并前检查机制。我们可以通过识别并弥补代码库测试中的漏洞来缓解这个问题,这些漏洞曾导致错误变更被合并到原始代码库中。我们也可以接受有些问题总是会漏网,打造高质量产品并非仅仅是提交一个绿色 PR 就能完成。许多代码库在合并前并没有也无法运行完整的测试套件。然而,我们也可以投入资源,针对刚刚构建的产品运行场景测试。这正是我们传统的依赖流系统所不擅长的。
任何完整的产品场景测试都依赖于组件依赖项更新到达 dotnet/sdk 代码库。在此之前,我们没有完整的 .NET 产品可供测试。任何尝试都只是某种“拼凑而成”的产品。注意:很多端到端测试实际上只是 dotnet/sdk 代码库级别的 PR/CI 测试。然而,变更需要一段时间才能在代码库中生效,并在测试中显现出来。
源构建方法论确保每次组件变更都会生成完整的产品版本,无论该组件在产品构建图中的位置如何。这意味着我们有机会针对每次组件变更创建并运行一套全面的测试。这些测试应着重覆盖产品的大部分功能。如果测试通过,则可以合理预期 .NET 的运行方式能够支持开发工作的顺利进行。
提供一种构建 .NET 所包含所有内容的方法
Linux 发行版源代码构建服务专注于 1xx 版本 SDK、ASP.NET Core 和运行时库中内置的资源。它构建的软件包支持创建这些布局。正如我们之前在预构建版本淘汰过程中所看到的,这种专注是满足发行版合作伙伴构建要求的必要条件。如果我们想要构建微软发布的完整版本,就不能再局限于这种专注。
在某些领域,扩展我们的关注点很简单,而在另一些领域则很困难。在某些方面,我们只是放宽了限制,并将更多功能重新引入构建过程。我们需要允许从源中恢复预构建的二进制文件(例如签名功能)。我们需要构建所有 TFM,而不是剔除 .NET Framework 目标。我们需要构建最初被排除在以源代码构建为中心的共享源代码布局之外的组件,例如 Windows Desktop、Winforms、WPF、EMSDK 等。更困难的是连接。回想一下,Linux 发行版源代码构建是单一布局、单台机器、单次调用。这足以生成布局,但 .NET 中还有许多其他工件需要在多台机器上构建。这些工件打破了单机垂直性的概念。
理想情况下,我们会重新架构产品以避免这些连接。但通常很难在不影响客户体验或增加产品本身复杂性的前提下做到这一点。我们无法在不影响客户的情况下简化 SDK,即使是在企业级产品的主要版本更新中,这也很难做到。过去的决策会极大地影响未来的选择。最终,我们必须通过产品构建实践尽可能地消除连接。任何剩余的连接我们都只能接受。构建过程必须经过架构设计,以便通过一系列构建步骤在多台机器上运行。
原文链接
Reinventing how .NET Builds and Ships (Again)
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。
如有任何疑问,请与我联系 (MingsonZheng@outlook.com)
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
相关推荐