随着.NET Core 3.0 预览版 6 的推出,我们认为有必要简要回顾一下基础设施系统的历史,以及在过去一年左右时间里所做的重大改进。如果你对构建基础设施感兴趣,或者希望了解如何构建像.NET Core 这样大的产品,那么这篇文章将非常有趣。
.NET Core 简史
从 3 年前开始,.NET Core 项目就与传统的微软项目有很大的不同。
在 GitHub 上公开开发
由集成在一起的独立 Git 存储库组成,而不是一个庞大的存储库
面向许多平台
它的组件可以在多个“载体”中发布(例如 Roslyn 作为 Visual Studio 和 SDK 的组件发布)
我们早期的基础设施决策是围绕必要性和便利性做出的。我们使用 Jenkins 进行 GitHub PR 和 CI 验证,因为它支持跨平台的 OSS 开发。我们的官方构建版本位于 Azure DevOps(当时称为 VSTS)和 TeamCity(由 ASP 使用)中,其中有签名和其他关键的交付基础设施。我们搭配使用手动更新包依赖项版本和自动化 GitHub PR 的方法将存储库集成在一起。团队独立地构建了他们需要的工具来进行打包、布局、本地化,以及在大型开发项目中出现的所有其他常见任务。虽然不是很理想,但在某种程度上,这在早期已经运行得足够好了。随着项目从.NET Core 1.0 和 1.1 发展到 2.0 以及更高版本,我们希望投资于进一步整合的技术栈、更快的交付周期和更简单的服务。我们希望每天多次使用最新的运行时来生成一个新的 SDK。我们希望所有这些都不降低独立存储库的开发速度。
.NET Core 面临的许多基础设施方面的挑战都源于存储库结构的隔离和分布式特性。尽管多年来它变化很大,但该产品是由 20 到 30 个独立的 Git 存储库组成(ASP.NET Core 直到最近还比它多得多)。一方面,拥有许多独立的开发竖井会使这些竖井中的开发非常高效;开发人员可以在库中快速迭代,而不用担心栈的其他部分。另一方面,它使得整个项目的创新和集成效率大大降低。下面是一些例子:
如果我们需要推出新的签名或打包特性,那么跨这么多使用不同工具的独立存储库进行签名或打包成本将非常高。
变更跨栈移动缓慢且代价高昂。存储库库中位于栈“底”的修复程序和和特性(例如 corefx 库)可能几天内在 SDK 中(栈“顶”)都看不到。如果我们在 dotnet/corefx 中做了一个修复,那么这个更改必须被构建,并且新版本将会进入任何引用它的上层组件中(例如 dotnet/core-setup 和 ASP.NET Core),它将在那里测试、提交和构建。然后,这些新组件需要将这些新输出提供给栈的更上层,以此类推,直到最上层。
在所有这些情况下,都有可能在许多层面上出现失败,从而进一步减缓进程。随着.NET Core 3.0 计划的正式启动,很明显,如果不对基础设施进行重大更改,我们就无法创建所需范围的版本。
三管齐下
为了减轻痛苦,我们三管齐下:
共享工具(即Arcade)——投资跨存储库的共享工具;
系统整合(Azure DevOps)——弃用 Jenkins,转而采用 Azure DevOps 实现 GitHub CI。将我们的官方构建从传统 VSTS 时代的过程转成现代化的“配置即代码(config-as-code)”。
自动化依赖流和发现(Maestro)——显式跟踪存储库之间的依赖关系,并以很快的节奏自动更新它们。
Arcade
在.NET Core 3.0 之前,有 3 到 5 种不同的工具实现分散在不同的存储库中,这和你如何计算有关。
核心运行时存储库(dotnet/coreclr、dotnet/corefx和dotnet/core-setup)有dotnet/buildtools;
ASP.NET Core 存储库有aspnet/KoreBuild;
dotnet/symreader等多个存储库使用Repo Toolset;
其他一些单独的存储库有单独的实现。
虽然在这个世界上,每个团队都可以定制他们的工具,只构建他们需要的东西,但这确实有一些显著的缺点:
开发人员在存储库之间切换时效率更低
例如:当开发人员从 dotnet/corefx 切换到 dotnet/core-sdk 时,存储库的“语言”是不同的。她输入什么来构建和测试?日志放在哪里?如果她需要在存储库中添加一个新项目,该如何做?
需要的每个特性都要构建 N 次
例如: .NET Core 生成了大量的 NuGet 包。虽然有一些变化(例如,共享运行时包如出自 dotnet/core-setup 的 Microsoft.NETCore.App 就与 Microsoft.AspNet.WebApi.Client 等“普通”包的构建方式不同),但生成它们的步骤非常相似。遗憾的是,由于存储库在布局、项目结构等方面的差异,如何实现这些打包任务方面也产生了差异。存储库如何定义应该生成什么包、这些包中包含什么、它们的元数据等等。如果没有共享工具,团队通常更容易实现另一个打包任务,而不是重用另一个。这当然会导致资源压力。
借助Arcade,我们努力使所有的存储库采用公共的布局、存储库“语言”和任务集(可能的话)。这并非没有陷阱。任何一种共享工具最终都会解决一些“刚刚好”问题。如果共享工具过于规范,那么在任何规模的项目中进行所需的定制都将变得非常困难,并且更新该工具也将变得非常困难。使用新的更新很容易破坏存储库。构建工具遭受了这种痛苦。使用它的存储库与它紧密耦合,以至于它不仅不能用于其他存储库,而且对构建工具进行任何更改常常会以意想不到的方式伤害用户。如果共享工具不够规范,那么存储库在使用工具时往往会出现差异,并且推出更新通常需要在每个单独的存储库中做大量的工作。那么,为什么要共享工具呢?
实际上,Arcade 尝试同时使用了这两种方法。它将公共存储库“语言”定义为脚本集(请参阅eng/common)、公共存储库布局和作为 MSBuild SDK 推出的公共构建目标集。选择完全采用 Arcade 的存储库具有可预测的行为,使得更改很容易在存储库之间传播。不希望这样做的存储库可以从提供基本功能(如签名和打包)的各种 MSBuild 任务包中进行选择,这些任务包在所有存储库中看起来都是一样的。当我们对这些任务进行更改时,我们会尽力避免破坏性更改。
让我们来看看 Arcade 提供的主要特性,以及它们如何集成到我们更大的基础设施中。
公共构建任务包——这些是 MSBuild 任务的基本层,可以单独使用,也可以作为 Arcade SDK 的一部分。它们是“付费游戏”(因此得名 Arcade)。它们提供了一组在大多数.NET Core 存储库中都需要的公共功能:
输出发布(到库间推送): Microsoft.DotNet.Build.Tasks.Feed
公共存储库目标和行为——这些都是 MSBuild SDK 的一部分,称为“Arcade SDK”。借助它,存储库可以选择默认的 Arcade 构建行为、项目和工件布局等。
公共存储库“语言”——一组公共脚本文件,使用依赖流在所有 Arcade 存储库之间同步(稍后将详细介绍)。这些脚本文件为采用 Arcade 的存储库引入了一种通用的“语言”。对于开发人员来说,在这些存储库之间切换变得更加平稳。此外,由于这些脚本是在存储库之间同步的,所以对 Arcade 存储库中的原始副本进行新的更改可以快速地将新特性或行为引入完全采用共享工具的存储库中。
共享 Azure DevOps 作业和步骤模板——虽然定义公共存储库“语言”的脚本主要面向与人交互,Arcade 也有一组Azure DevOps作业和步骤模板,这些模板允许 Arcade 存储库与 Azure DevOps CI 系统交互。与常见的构建任务包一样,步骤模板形成了一个基本层,几乎每个存储库都可以使用它(例如发送构建遥测)。作业模板形成更完整的单元,使存储库不必担心 CI 流程的细节。
Azure DevOps
如上所述,较大的团队通过 2.2 版本使用了一个 CI 系统的组合:
用于ASP.NET Core GitHub PR 的 AppVeyor 和 Travis;
用于 ASP.NET 正式构建的 TeamCity;
用于.NET Core GitHub PR 和滚动验证的 Jenkins;
用于所有非ASP.NET Core 正式构建的经典(非 YAML) Azure DevOps 工作流。
许多差异仅仅是出于必要性。Azure DevOps 不支持公共 GitHub PR/CI 验证,所以ASP.NET Core 转向 AppVeyor 和 Travis 来填补这个空白,而.NET Core 则投资于 Jenkins。经典 Azure DevOps 对构建编排没有太多的支持,所以ASP.NET Core 团队求助于 TeamCity,而.NET Core 团队则在 Azure DevOps 之上构建了一个名为 PipeBuild 的工具来帮助克服困难。所有这些差异都是非常昂贵的,即使是以一些不明显的方式:
虽然 Jenkins 很灵活,但维护一个大型的(大约 6000 道 8000 个任务)、稳定的设施是一项重大的任务。
在经典 Azure DevOps 之上构建我们自己的业务流程需要很多折衷方案。实际上,签入管道作业描述不是人类可读的(它们只是手工创建的构建定义的 json 描述),机密管理难以应对,当我们试图处理构建需求中的巨大差异时,它们很快就被过度参数化了。
当在不同的系统中定义了正式构建、夜间验证和 PR 验证过程时,共享逻辑就变得非常困难。开发人员在进行流程更改时必须格外小心,因为破坏性更改很常见。我们在一个特殊的脚本文件中定义了 Jenkins PR 作业,TeamCity 有许多手动配置的作业,AppVeyor 和 Travis 使用他们自己的 yaml 格式,Azure DevOps 上有我们构建的不为人知的自定义系统。在 PR 中更改构建逻辑很容易破坏正式 CI 构建。为了缓解这种情况,我们在编写脚本时尽量保持与正式 CI 和 PR 构建相同的逻辑,但随着时间的推移,总会出现差异。一些差异,比如在构建环境中,基本上是不可能完全消除的。
对工作流进行更改的实践千差万别,常常难以理解。开发人员了解到,Jenkins 中用于更新 PR 逻辑的 netci.groovy 文件没有转换为用于正式 CI 构建的 PipeBuild json 文件。因此,系统知识通常只有少数团队成员掌握,这在大型组织中并不理想。
当 Azure DevOps 开始推出基于 YAML 的构建管道和对公共 GitHub 项目的支持时,随着.NET Core 3.0 的启动,我们意识到,我们拥有一个独特的机会。有了这种新的支持,我们可以将现在所有的工作流从单独的系统转移到现代的 Azure DevOps 中,并对我们处理正式 CI 和 PR 工作流的方式进行一些更改。我们的工作大致如下:
把我们所有的逻辑都保存在 GitHub 中的代码中。所有地方都使用 YAML 管道。
分别有一个公共项目和一个私有项目。
这个公共项目将通过 GitHub 存储库和 PR 运行所有的公共 CI,我们还会一如既往
在与公共 GitHub 存储库相匹配的存储库中,将运行正式 CI 的私有项目会作为我们需要任何私有更改的地方
只有私有项目会访问受限资源
在正式 CI 和 PR 构建之间共享相同的 YAML。使用模板表达式来辨别公共项目和私有项目中行为必然不同的地方,或者只有在私有项目中可用的资源才会被访问。虽然这常常使整个 YAML 定义有点混乱,但这意味着:
降低了过程更改时构建中断的可能性
开发人员只需要更改一组地方就可以更改正式 CI 和 PR 流程。
为常见任务构建 Azure DevOps 模板,最小化样板文件 YAML 的重复,并使用依赖流轻松推出更新(例如遥测)。
到目前为止,所有主要的.NET Core 3.0 存储库都在 Azure DevOps 上进行公共 PR 和正式 CI。一个很好的例子是dotnet/arcade本身的正式构建/PR 管道。
Maestro 和依赖流
.NET Core 3.0 基础架构的最后一块拼图就是我们所说的依赖流。这并不是.NET Core 独有的概念。除非它们是完全自包含的,否则大多数软件项目都包含对其他软件的某种版本化引用。在.NET Core 中,这些包通常表现为 NuGet 包。当我们需要库提供的新特性或修复时,我们通过更新项目中引用的版本号来获取这些新更新。当然,这些包也可能有对其他包的版本化引用,那些其他包可能有更多的引用,等等。这就形成了一张图。当每个存储库拉取其输入依赖项的新版本时,更改将在图中流动。
一个复杂的图
大多数软件项目的主要开发生命周期(开发人员经常从事的工作)通常涉及少量相互关联的存储库。输入依赖关系通常是稳定的,更新很少。当他们确实需要更改的时候,通常是手工操作。开发人员评估输入包的可用版本,选择合适的版本,然后提交更新。但在.NET Core 中并非如此。组件需要独立,以不同的节奏交付,并具有高效的内循环开发体验,这导致了大量具有大量相互依赖关系的存储库。相互依赖关系也形成了一个相当深的图:
Dotnet/core-sdk 存储库作为所有子组件的聚合点。我们提供了一个特定的 dotnet/core-sdk 构建,它描述了所有其他引用的组件。
我们还希望新的输出能够快速通过这个图,以便尽可能多地验证最终产品。例如,我们期望ASP.NET Core或.NET Core 运行时的最新片段尽可能多地在 SDK 中表现自己。本质上,这意味着定期快节奏地更新每个存储库中的依赖项。在一个足够大的图中,就像.NET Core 一样,这很快就变成了一个不可能手工完成的任务。这种规模的软件项目可能会通过以下几种方法来解决这个问题:
“自漂移(Auto-floating)”输入版本——在这个模型中,dotnet/core-sdk 可能会引用 Microsoft.NETCore.App,后者来自 dotnet/core-setup,允许 NuGet 漂移到最新的预发布版本。虽然这样做有效,但它也有一些很大的缺点。构建成为不确定的。签出旧的 git SHA,而构建不一定使用相同的输入或生成相同的输出。复现 Bug 变得很困难。在 dotnet/core-setup 中,错误的提交会破坏除 PR 和 CI 检查之外的任何存储库的输出。构建编排成为一项主要任务,因为构建中的不同机器可能在不同的时间还原程序包,从而产生不同的输入。所有这些问题都是“可以解决的”,但是需要巨大的投资和不必要的基础设施的复杂性。
“复合(Composed)”构建——在这个模型中,使用每个输入存储库中的最新 git SHA,按照依赖关系的顺序一次性独立构建整个图。构建的每个阶段的输出都被输入到下一个阶段。存储库的输入阶段会有效地覆盖其输入依赖项版本号。在成功构建的末尾,输出将被发布,所有存储库将更新它们的输入依赖项,以匹配刚刚构建的内容。这是对自动漂移版本号的改进,因为单个存储库构建不会被其他存储库中的错误签入自动破坏,但是它仍然有很大的缺点。破坏性更改几乎不可能在存储库之间有效地流动,重现失败仍然存在问题,因为存储库中的源代码常常与实际构建不匹配(因为输入版本在源代码控制之外被覆盖)。
自动化依赖流——在此模型中,外部基础设施用于以确定的、经过验证的方式在存储库之间自动更新依赖项。存储库显式地在源代码中声明它们的输入依赖项和关联版本,并“订阅”来自其他存储库的更新。当生成新的构建时,系统发现匹配的订阅,就更新任何已声明的输入依赖项,并使用更改打开 PR。此方法提高了可再现性、使破坏性更改流动的能力,并允许存储库所有者控制更新的执行方式。缺点是,它可能比其他两种方法都要慢得多。更改只能从栈底流向栈顶,其速度与每个存储库中 PR 和正式 CI 时间的总和相同。
.NET Core 已经尝试了所有 3 种方法。我们在 1.x 的早期漂移版本。在 2.0 中实现了一定程度的自动化依赖流,并为 2.1 和 2.2 构建了一个复合构建。在 3.0 中,我们决定大量投资于自动化依赖流,放弃其他方法。我们想在一些重要的方面改进我们以前的 2.0 基础设施:
简化对产品中实际内容的跟踪——在任何给定的存储库中,通常都可以确定哪些版本的组件被用作输入,但几乎总是很难确定这些组件是在哪里构建的,它们来自于什么 git SHA,它们的输入依赖关系是什么,等等。
减少必须的人类交互——大多数依赖项更新都很普通。在通过验证时自动合并更新 PR,以加快流程。
将依赖关系流信息与存储库状态分开——存储库应该只包含依赖关系图中节点的当前状态信息。它们不应该包含关于转换的信息,比如应该在什么时候进行更新,从什么来源获取,等等。
基于“意图”而不是分支的流依赖关系——因为.NET Core 是由相当多的半自治团队组成的,这些团队具有不同的分支思想、不同的组件发布周期等等,所以不使用分支代替意图。团队应该根据这些输入的目的,而不是它们来自哪里,来定义将哪些新的依赖项拉入存储库。此外,这些输入的目的应由生产这些输入的小组宣布。
“意图”应该推迟到构建时——为了提高灵活性,避免在构建完成之前分配构建的意图,我们允许声明多个意图。在构建时,输出只是在某个 git SHA 上构建出的一堆输出片段。就像在 Azure DevOps 构建的输出上运行一个发布管道,本质上为输出分配了一个目的一样,在依赖流系统中为构建分配一个意图也开始了基于意图的流依赖关系的过程。
考虑到这些目标,我们创建了一个名为 Maestro++的服务和一个名为“darc”的工具来处理依赖流。Maestro++处理数据和依赖项的自动移动,而 darc 为 Maestro++提供了一个人机界面,以及一个进入整个产品依赖项状态的窗口。依赖流基于 4 个基本概念:依赖信息、构建、通道和订阅。
构建、通道和订阅
依赖信息——在每个存储库的eng/Version.Details中,都有一个存储库的输入依赖项声明,以及关于这些输入依赖项的源信息。读取此文件,然后跟踪每个输入依赖项的 repository+sha 组合的传递关系,生成产品依赖关系图。
构建——构建只是 Azure DevOps 构建上的 Maestro++视图。构建标识存储库+sha、总体版本号、在构建中生成的完整资产集及其位置(例如 NuGet 包、zip 文件、安装程序等)。
通道——通道表示意图。将通道看作一个跨存储库分支可能很有用。可以将构建分配给一个或多个通道,从而将意图分配给输出。通道可以与一个或多个发布管道相关联。将构建分配给通道将激活发布管道并导致发布。构建的资产位置是根据发布活动更新的。
订阅——订阅表示转换。它将位于特定通道上的构建的输出映射到另一个存储库的分支上,并提供关于应该在何时进行这些转换的附加信息。
这些概念的设计使得存储库所有者不需要栈或其他团队流程的全局知识就可以参与依赖流。他们只需要知道三件事:
他们所做的构建的意图(如果有的话),以便可以分配通道。
他们的输入依赖关系以及它们是从什么存储库生成的。
他们希望从哪些通道更新这些依赖关系。
例如,假设我拥有 dotnet/core-setup 存储库。我知道,我的主分支是为日常的.NET Core 3.0 开发生成了一些片段。我想分配新的构建到预先声明的“.NET Core 3.0 开发”通道。我还知道,我有几个 dotnet/coreclr 和 dotnet/corefx 包输入。我不需要知道它们是如何产生的,或者来自哪个分支。所有我需要知道的是,我想每天从“.NET Core 3.0 开发”通道获取最新的 dotnet/corefx 输入,并在“.NET Core 3.0 开发”通道每次出现最新的 dotnet/corefx 输入时获取它们。
首先,我加入了一个eng/Version.Details文件。然后,我使用“darc”工具来确保主分支上的每个新构建的存储库都默认分配给“.NET Core 3.0 开发”通道。接下来,我设置了订阅,从.NET Core 3.0 开发通道中获取用于构建 dotnet/corefx、dotnet/coreclr、dotnet/standard 等的输入。这些订阅有一个节奏和自动合并策略(例如每周或每次构建)。
当每个订阅的触发器被激活时,Maestro++将根据已声明且与新生成的输出有交互的依赖项更新 core-setup 存储库中的文件(eng/Version.Details.xml、eng/Versions.props 等)。它打开一个 PR,一旦配置的检查得到满足,就自动合并 PR。
这将在主分支上生成一个新的 core-setup 构建。完成后,自动将构建分配给“.NET Core 3.0 开发”通道。.NET Core 3.0 开发通道有一个与之关联的发布管道,它将构建的输出构件(例如包和符号文件)推送到一组目标位置。因为这个通道是为日常的公共开发构建而设计的,所以包和符号被推送到不同的公共位置。发布管道完成后,通道分配就完成了,在此事件上激活的任何订阅都将被触发。随着更多组件添加进来,我们将构建一个完整的流图,它表示存储库之间的所有自动流。
.NET Core 3 开发通道的流图,包括.NET Core 3 Dev 流的其他通道(例如,Arcade 的“.NET Tools Latest”)。
一致和不一致
对.NET Core 依赖关系图状态的可见性增强突出了一个存在的问题:当同一个组件的多个版本在图中的不同节点上被引用时会发生什么?.NET Core 依赖关系图中的每个节点都可以将依赖关系流到多个其他节点。比如 Microsoft.NETCore.App 依赖,产生于 dotnet/core-setup,流向 dotnet/toolset、dotnet/core-sdk、aspnet/extensions 和其他一些地方。由于拉取请求验证时间的变化、对破坏性变更的响应需要以及所需的订阅更新频率的不同,此依赖项的更新将在这些地方以不同的速率提交。随着这些存储库流向其他地方,并最终在 dotnet/core-sdk 下合并,Microsoft.NETCore.App 可能会有许多不同版本在整个图中被间接引用。这叫做不一致。当依赖关系图中的每个产品依赖关系只有一个版本被引用时,该图就是一致的。如果可能的话,我们总是努力生产出一致的产品。
非一致性会导致哪些问题?不一致性表示可能的错误状态。举个例子,让我们看看 Microsoft.NETCore.App。这个包表示特定的 API 表面。虽然存储库依赖关系图中可能会引用 Microsoft.NETCore.App 的多个版本,但 SDK 只提供一个。这个运行时必须满足可在该运行时上执行的间接引用组件(例如 WinForms 和 WPF)的所有需求。如果运行时不满足这些需求(例如破坏性 API 变更),可能就会发生故障。在不一致的图中,因为所有存储库都没有使用相同版本的 Microsoft.NETCore.App,有可能错过了一个破坏性的变更。
这是否意味着不一致始终是一种错误状态?不。例如,我们假设图中 Microsoft.NETCore.App 的不一致只代表一个非破坏性 JIT Bug 修复 coreclr 中的一个变更。从技术上讲,微软没有必要在图中的每一点上获取新的 Microsoft.NETCore.App。只需针对新的运行时交付相同的组件就足够了。
如果不一致只是偶尔的问题,那么我们为什么还要努力才能推出一致的产品呢?因为很难确定什么时候不一致无关紧要。简单地将一致性作为所需状态进行交付,要比试图理解不一致的组件之间的任何语义差异对最终产品所产生的影响更容易。这是可以做到的,但是从构建频率来说,它是时间密集型的,并且容易出错。强制将一致性作为默认状态更安全。
依赖流的好处
随着存储库图越来越大,所有这些自动化和跟踪都有许多明显的优势。它为我们解决日常生活中的实际问题提供了很多可能性。虽然我们刚刚开始探索这个领域,但系统已经可以开始回答一些有趣的问题,并处理以下场景:
在 dotnet/core-sdk 的 git SHA A 和 SHA B 之间发生了什么“真正”的变化?通过遍历 Version.Details.xml 文件构建完整的依赖关系图,我可以识别图中发生的非依赖关系变更的变化。
修复程序需要多长时间才能出现在产品中?通过结合存储库流图和每个存储库的遥测,我们可以估计将修复从存储库 A 移动到存储库 B 需要多长时间。这在发布的后期尤其有价值,因为它帮助我们在查看是否要进行特定的更改时,做出更准确的成本/收益评估。例如:我们是否有足够的时间来处理这个修复并完成我们的场景测试?
Core-sdk 构建及其所有输入构建生成的所有资产在什么位置?
在服务发布时,我们想要进行特定的修复,但又不想进行其他修复。通道可以被放置到模式中,模式允许特定的修复程序自动通过图,但其他修复程序会被阻塞或需要批准。
未来展望
随着.NET Core 3.0 逐步结束,我们正在寻找新的领域来改进。虽然计划仍处于(非常)初期的阶段,但我们预计在以下几个关键领域进行投资:
缩短将修复转换为可交付的、一致的产品的时间——依赖关系图中的跳数非常重要。这允许存储库在其处理过程中有很大的自治权,但是增加了端到端的“构建”时间,因为每个跳转都需要提交和正式构建。我们希望大大减少端到端的时间。
改善我们的基础设施遥测——如果我们能更好地跟踪我们失败的地方、我们的资源使用情况、我们的依赖状态等等,我们就可以更好地确定我们需要在哪里投资,以交付更好的产品。在.NET Core 3.0 中,我们朝着这个方向采取了一些步骤,但是我们还有很长的路要走。
多年来,我们已经对基础设施进行了相当大的改进。从 Jenkins 到 Azure DevOps,从手工依赖流到 Maestro++,从许多工具实现到一个工具实现,我们对.Net Core 3.0 所做的改变是一个巨大的进步。我们已经为开发和交付比以往任何时候都更可靠、更令人兴奋的产品做好了准备。
查看英文原文:The Evolving Infrastructure of .NET Core
评论