Liam W
封面

.NET Standard 来日苦短去日长

作者
王亮·发表于 3 年前

作者:Richard
翻译:精致码农-王亮
原文:http://dwz.win/Q4h

自从 .NET 5 宣贯以来,很多人都在问这对 .NET Standard 意味着什么,它是否仍然重要。在这篇文章中,我将解释 .NET 5 是如何改进代码共用并取代 .NET Standard 的,我还将介绍什么情况下你仍然需要 .NET Standard。

概要

.NET 5 将是一个具有统一功能和 API 的单一产品,可用于 Windows 桌面应用程序、跨平台移动应用程序、控制台应用程序、云服务和网站。

为了更好地说明这一点,我们更新了这篇[1]关于 TFM (Target Framework Names) 介绍的文章(译文:.NET 5 中 Target Framework 详解),现支持的 TFM 如下:

  • .net5.0,表示代码可在任意平台运行,它合并并替换了 netcoreappnetstandard 这两个名称。这个 TFM 通常只包括跨平台的技术(除了一些为了满足实用性而作出让步的 API,就像我们在 .NET Standard 中所做的那样)。
  • net5.0-windows(还有后面会增加的net6.0-androidnet6.0-ios),这些 TFM 表示 .NET 5 特定于操作系统的风格,包含 net5.0 和特定于操作系统的功能。

我们不会再发布 .NET Standard 的新版本,但是 .NET 5 和所有未来的版本将继续支持 .NET Standard 2.1 和更早的版本。你应该将 net5.0(和未来的版本)视为共享代码的基础。

由于 net5.0 是所有这些新 TFM 的共用的基础,这意味着运行时、库和新的语言特性都会围绕这个版本号进行协调。例如,为了使用 C# 9,你需要使用 net5.0net5.0-windows

如何选择 Target

.NET 5 和所有未来的版本将继续支持 .NET Standard 2.1 和更早的版本,从 .NET Standard 重新 Target 到 .NET 5 的唯一原因是为了获得更多运行时特性、语言特性或 API 支持。所以,你可以把 .NET 5 想象成 .NET Standard 的 vNext。

那新代码呢?该从 .NET Standard 2.0 开始还是直接从 .NET 5 开始?这得视情况而定。

  • 应用程序组件,如果你要将你的应用程序以类库的形式分解成多个组件,我建议将 netX.Y 作为 TFM,netX.Y 中的 X.Y 是应用程序(或多个应用程序)的 .NET 最低版本号。为了简单起见,你可能希望所有组成你的应用程序的 Project 都使用相同的 .NET 版本,因为这样可以保证各处的代码都可以使用相同的 BCL 特性。
  • 可重用库,如果你正在构建计划在 NuGet 上发布的可重用库,你将需要考虑适用范围和可用新特性之间的权衡。.NET Standard 2.0 是 .NET Framework 支持的最高 .NET Standard 版本,所以它可以满足你的大部分使用场景。我们通常建议不要将 Target 锁定在 .NET Standard 1.x 上,因为不值得再为此增添不必要的麻烦。如果你不需要支持 .NET Framwork,那么你可以选择 .NET Standard 2.1 或者 .NET 5,大多数代码可能可以跳过 .NET Standard 2.1 直接转到 .NET 5。

那么,你应该怎么做呢?我的建议是,已被广泛使用的库可能需要同时提供 .NET Standard 2.0 和 .NET 5 支持。支持 .NET Standard 2.0 将使你的库适用性更广,而支持 .NET 5 则确保你可以为已经在 .NET 5 上的用户使用最新的平台特性。

几年后,可重用库的选择将只涉及 netX.Y 版本,这基本上是构建 .NET 库的一惯做法——你通常要支持一段时间较老的版本,以确保没有升级最新 .NET 版本的用户依然可以使用你的库。

总结一下:

  • 在 .NET Framework 和所有其他平台之间共享代码,使用 netstandard2.0
  • 在 Mono、Xamarin 和 .NET Core 3.x 之间共享代码,使用 netstandard2.1
  • 往后的共享代码,使用 net5.0

.NET 5 如何解决 .NET Standard 存在的问题

.NET Standard 使得创建适用于所有 .NET 平台的库变得更加容易,但是 .NET Standard 仍然存在三个问题:

  1. 它的版本更新很慢[2],这意味着你不能轻松地使用最新的特性。
  2. 它需要一个解码环[3]来将版本映射到 .NET 实现。
  3. 它公开了特定于平台的特性[4],这意味着你不能静态地验证代码是否真正可移植。

让我们看看 .NET 5 将如何解决这三个问题。

问题 1:.NET Standard 版本更新慢

在设计 .NET Standard[5] 时,.NET 平台还没有在实现层次上融合,这使得编写需要在不同环境下工作的代码变得困难,因为不同的工作代码使用的是不同的 .NET 实现。

.NET Standard 的目标是统一基础类库(BCL)的特性集,这样你就可以编写一个可以在任何地方运行的单一库。这为我们提供了很好的服务:前 1000 个软件包中有超过 77% 支持 .NET Standard。如果我们看看 NuGet.org 上所有在过去 6 个月里更新过的软件包,采用率是 58%。

但是只标准化 API 就会产生额外的付出,它要求我们在添加新 API 时进行协调——这一直在发生。.NET 开源社区(包括.NET 团队)通过提供新的语言特性、可用性改进、新的交叉(cross-cutting)功能(如 Span<T>)或支持新的数据格式或网络协议,不断对 BCL 进行创新。

而我们虽然可以以 NuGet 包的形式提供新的类型,但不能以这种方式在现有类型上提供新的 API。所以,从一般意义上讲,BCL 的创新需要发布新版本的 .NET 标准。

在 .NET Standard 2.0 之前,这并不是一个真正的问题,因为我们只对现有的 API 进行标准化。但在 .NET Standard 2.1 中,我们对全新的 API 进行了标准化,这也是我们看到相当多摩擦的地方。

这种摩擦从何而来?

.NET 标准是一个 API 集,所有的.NET 实现都必须支持,所以它有一个编辑方面[6]的问题,所有的 API 必须由 .NET Standard 审查委员会[7]审查。该委员会由 .NET 平台实现者以及 .NET 社区的代表组成。其目标是只对我们能够真正在所有当前和未来的 .NET 平台中实现的 API 进行标准化。这些审查是必要的,因为 .NET 协议栈有不同的实现,有不同的限制。

我们预测到了这种类型的摩擦,这就是为什么我们很早就说过,.NET 标准将只对至少一个 .NET 实现中已经推出的 API 进行标准化。这乍一看似乎很合理,但随后你就会意识到,.NET Standard 不可能频繁地更新。所以,如果一个功能错过了某个特定的版本,你可能要等上几年才能使用,甚至可能要等更久,直到这个版本的 .NET Standard 得到广泛支持。

我们觉得对于某些特性来说,机会损失太大,所以我们做了一些不自然的行为,将还没有推出的 API 标准化(比如 AsyncEnumerable<T>)。对所有的功能都这样做实在是太昂贵了,这也是为什么有不少功能还是错过了 .NET Standard 2.1 这趟列车的原因(比如新的硬件特性)。

但如果有一个单一的代码库呢?如果这个代码库必须支持所有与 .NET 至今所实现功能有所不同的特性,比如同时支持及时编译(JIT)和超前编译(AOT)呢?

与其在事后才进行这些审查,不如从一开始就将所有这些方面作为功能设计的一部分。在这样的世界里,标准化的 API 集从构造上来说,就是通用的 API 集。当一个功能实现后,因为代码库是共享的,所以大家就已经可以使用了。

问题 2:.NET Standard 需要解码环

将 API 集与它的实现分离,不仅仅是减缓了 API 的可用性,这也意味着我们需要将 .NET Standard 版本映射到它们的实现上[3]。作为一个长期以来不得不向许多人解释这个表格的人,我已经意识到这个看似简单的想法是多么复杂。我们已经尽力让它变得更简单,但最终,这种复杂性是与生俱来的,因为 API 集和实现是独立发布的。

我们统一了 .NET 平台,在它们下面又增加了一个合成平台,代表了通用的 API 集。从很现实的意义上来说,这幅漫画是很到位的表达了这个痛点:

如果不能实现真正意义上的合并,我们就无法解决这个问题,这正是 .NET 5 所做的:它提供了一个统一的实现,各方都建立在相同的基础上,从而得到相同的 API 和版本号。

问题 3:.NET Standard 公开了特定平台 API

当我们设计 .NET Standard 时,为了避免过多地破坏库的生态系统,我们不得不做出让步[4]。也就是说,我们不得不包含一些 Windows 专用的 API(如文件系统 ACL、注册表、WMI 等)。今后,我们将避免在 net5.0net6.0 和未来的版本中加入特定平台的 API。然而,我们不可能预测未来。例如,我们最近为 Blazor WebAssembly 增加了一个新的 .NET 运行环境,在这个环境中,一些原本跨平台的 API(如线程或进程控制)无法在浏览器的沙箱中得到支持。

很多人抱怨说,这类 API 感觉就像“地雷”–代码编译时没有错误,因此看起来可以移植到任何平台上,但当运行在一个没有给定 API 实现的平台上时,就会出现运行时错误。

从 .NET 5 开始,我们将提供随 SDK 发布的默认开启的分析器和代码修复器。它包含平台兼容性分析器,可以检测无意中使用了目标平台并不支持的 API。这个功能取代了 Microsoft.DotNet.Analyzers.Compatibility NuGet 包。

让我们先来看看 Windows 特有的 API。

处理 Windows 特定 API

当你创建一个 Target 为 net5.0 为目标的项目时,你可以引用 Microsoft.Win32.Registry 包。但当你开始使用它时:

private static string GetLoggingDirectory()
{
    using (RegistryKey key = Registry.CurrentUser.OpenSubKey(@"Software\Fabrikam"))
    {
        if (key?.GetValue("LoggingDirectoryPath") is string configuredPath)
            return configuredPath;
    }

    string exePath = Process.GetCurrentProcess().MainModule.FileName;
    string folder = Path.GetDirectoryName(exePath);
    return Path.Combine(folder, "Logging");
}

你会得到以下警告:

CA1416: 'RegistryKey.OpenSubKey(string)' is supported on 'windows'
CA1416: 'Registry.CurrentUser' is supported on 'windows'
CA1416: 'RegistryKey.GetValue(string?)' is supported on 'windows'

你有三个选择来处理这些警告。

  1. 调用保护:在调用 API 之前,你可以使用 OperatingSystem.IsWindows() 来检查当前运行环境是否是 Windows 系统。

  2. 将调用标记为 Windows 专用:在某些情况下,通过 [SupportedOSPlatform("windows")] 将调用成员标记为特定平台也有一定的意义。

  3. 删除代码:一般来说,这不是你想要的,因为这意味着当你的代码被 Windows 用户使用时,你会失去保真度(fidelity)。但对于存在跨平台替代方案的情况,你应该尽可能使用跨平台方案,而不是平台特定的 API。例如,你可以使用一个 XML 配置文件来代替使用注册表。

  4. 抑制警告:当然,你可以通过 .editorconfig#pragma warning disable 来抑制警告。然而,当使用特定平台的 API 时,你应该更喜欢选项 (1) 和 (2)。

为了调用保护,可以使用 System.OperatingSystem 类上的新静态方法,示例:

private static string GetLoggingDirectory()
{
    if (OperatingSystem.IsWindows())
    {
        using (RegistryKey key = Registry.CurrentUser.OpenSubKey(@"Software\Fabrikam"))
        {
            if (key?.GetValue("LoggingDirectoryPath") is string configuredPath)
                return configuredPath;
        }
    }

    string exePath = Process.GetCurrentProcess().MainModule.FileName;
    string folder = Path.GetDirectoryName(exePath);
    return Path.Combine(folder, "Logging");
}

要将你的代码标记为 Windows 专用,请应用新的 SupportedOSPlatform 属性:

[SupportedOSPlatform("windows")]
private static string GetLoggingDirectory()
{
    using (RegistryKey key = Registry.CurrentUser.OpenSubKey(@"Software\Fabrikam"))
    {
        if (key?.GetValue("LoggingDirectoryPath") is string configuredPath)
            return configuredPath;
    }

    string exePath = Process.GetCurrentProcess().MainModule.FileName;
    string folder = Path.GetDirectoryName(exePath);
    return Path.Combine(folder, "Logging");
}

在这两种情况下,使用注册表的警告都会消失。

关键的区别在于,在第二个例子中,分析器现在会对 GetLoggingDirectory() 的调用发出警告,因为它现在被认为是 Windows 特有的 API。换句话说,你把平台检查的要求转给调用者放去做了。

[SupportedOSPlatform] 属性可以应用于成员、类型和程序集级别。这个属性也被 BCL 本身使用,例如,程序集 Microsoft.Win32.Registry 就应用了这个属性,这也是分析器最先就知道注册表是 Windows 特定 API 方法的原因。

请注意,如果你的目标是 net5.0-windows,这个属性会自动应用到你的程序集中。这意味着使用 net5.0-windows 的 Windows 专用 API 永远不会产生任何警告,因为你的整个程序集被认为是 Windows 专用的。

处理 Blazor WebAssembly 不支持的 API

Blazor WebAssembly 项目在浏览器沙盒内运行,这限制了你可以使用的 API。例如,虽然线程和进程创建都是跨平台的 API,但我们无法让这些 API 在 Blazor WebAssembly 中工作,它们会抛出 PlatformNotSupportedException。我们已经用 [UnsupportedOSPlatform("browser")] 标记了这些 API。

假设你将 GetLoggingDirectory() 方法复制并粘贴到 Blazor WebAssembly 应用程序中:

private static string GetLoggingDirectory()
{
    //...

    string exePath = Process.GetCurrentProcess().MainModule.FileName;
    string folder = Path.GetDirectoryName(exePath);
    return Path.Combine(folder, "Logging");
}

你将得到以下警告:

CA1416 'Process.GetCurrentProcess()' is unsupported on 'browser'
CA1416 'Process.MainModule' is unsupported on 'browser'

你可以用与 Windows 特定 API 基本相同的做法来处理这些警告。

你可以对调用进行保护:

private static string GetLoggingDirectory()
{
    //...

    if (!OperatingSystem.IsBrowser())
    {
        string exePath = Process.GetCurrentProcess().MainModule.FileName;
        string folder = Path.GetDirectoryName(exePath);
        return Path.Combine(folder, "Logging");
    }
    else
    {
        return string.Empty;
    }
}

或者你可以将该成员标记为不被 Blazor WebAssembly 支持:

[UnsupportedOSPlatform("browser")]
private static string GetLoggingDirectory()
{
    //...

    string exePath = Process.GetCurrentProcess().MainModule.FileName;
    string folder = Path.GetDirectoryName(exePath);
    return Path.Combine(folder, "Logging");
}

由于浏览器沙盒的限制性相当大,所以并不是所有的类库和 NuGet 包都能在 Blazor WebAssembly 中运行。此外,绝大多数的库也不应该支持在 Blazor WebAssembly 中运行。

这就是为什么针对 net5.0 的普通类库不会看到不支持 Blazor WebAssembly API 的警告。你必须在项目文件中添加 <SupportedPlatform> 项,明确表示你打算在 Blazor WebAssembly 中支持您的项目:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <SupportedPlatform Include="browser" />
  </ItemGroup>

</Project>

如果你正在构建一个 Blazor WebAssembly 应用程序,你不必这样做,因为 Microsoft.NET.Sdk.BlazorWebAssembly SDK 会自动做到这一点。

.NET 5 是 .NET Standard 和 .NET Core 的结合

.NET 5 及后续版本将是一个单一的代码库,支持桌面应用、移动应用、云服务、网站以及未来的任何 .NET 运行环境。

你可能会想“等等,这听起来很不错,但如果有人想创建一个全新的实现呢”。这也是可以的。但几乎没有人会从头开始一个新的实现。最有可能的是,它将是当前代码库(dotnet/runtime[8])的一个分支。例如,Tizen(三星智能家电平台)使用的是 .NET Core,只做了细小的改动,并在上面使用了三星特有的应用模型。

Fork 保留了合并关系,这使得维护者可以不断从 dotnet/runtime[8] 仓库中拉取新的变化,在不受其变化影响的领域受益于 BCL 创新,这和 Linux 发行版的工作方式非常相似。

当然,在某些情况下,人们可能希望创建一个非常不同的“种类”的 .NET,比如一个没有当前 BCL 的最小运行时。但这意味着它不能利用现有的 .NET 库生态系统,它也不会实现 .NET Standard。我们一般对这个方向的追求不感兴趣,但 .NET Standard 和 .NET Core 的结合并不妨碍这一点,也不会增加难度。

.NET 版本

作为一个库作者,你可能想知道 .NET 5 什么时候能得到广泛支持。今后,我们将在每年的 11 月发布 .NET 新版本,每隔一年发布一次长期支持(LTS)版本。

.NET 5 将在 2020 年 11 月正式发布,而 .NET 6 将在 2021 年 11 月作为 LTS 发布。我们创建了这个固定的时间表,使你更容易规划您的更新(如果你是应用程序开发人员),并预测对支持的 .NET 版本的需求(如果你是库开发人员)。

得益于 .NET Core 的并行安装(译注:一台机器可同时安装多个 .NET Core 版本,且向下兼容),它的新版本被采用速度相当快,其中 LTS 版本最受欢迎。事实上,.NET Core 3.1 是有史以来采用最快的 .NET 版本。

我们的期望是,每次发布(大版本)时,我们都会把所有框架名称连在一起发布。例如,它可能看起来像这样:

这意味着你心里可以有个预期,无论我们在 BCL 中做了什么创新,你都能在所有的应用模型中使用它,无论它们运行在哪个平台上。这也意味着,只要你运行最新版本的库,你总是可以在所有的应用模型消费最新的 net 框架带来的库。

这种模式消除了围绕 .NET Standard 版本的复杂性,因为每次我们发布时,你都可以假设所有的平台都会立即和完全支持新版本,而我们通过使用前缀命名惯例来巩固这一承诺。

.NET 的新版本可能会添加对其他平台的支持。例如,我们将通过 .NET 6 增加对 Android 和 iOS 的支持。相反,我们可能会停止支持那些不再相关的平台。这一点可以通过在 .NET 6 中不存在的 net5.0-someoldos 目标框架来说明。我们目前没有放弃一个平台支持的计划,那将是一个大问题,这不是预期的,若有我们会提前很久宣布。这也是我们对 .NET Standard 的模式,例如,没有新版本的 Windows Phone 实现了后面的 .NET Standard 版本。

为什么没有 WebAssembly 的 TFM

我们最初考虑为 WebAssembly 添加 TFM,如 net5.0-wasm。后来我们决定不这么做,原因如下:

  • WebAssembly 更像是一个指令集(如 x86 或 x64),而不是像一个操作系统,而且我们一般不提供不同架构之间有分歧的 API。

  • WebAssembly 在浏览器沙箱中的执行模型是一个关键的差异化,但我们决定只将其建模为运行时检查更有意义。类似于你对 Windows 和 Linux 的检查方式,你可以使用 OperatingSystem 类型。由于与指令集无关,所以该方法被称为 IsBrowser() 而不是 IsWebAssembly()

  • WebAssembly 有运行时标识符(RID)[9],称为 browserbrowser-wasm。它们允许包的作者在浏览器中针对 WebAssembly 部署不同的二进制文件。这对于需要事先编译成 WebAssembly 的本地代码特别有用。

如上所述,我们已经标记了在浏览器沙盒中不支持的 API,例如 System.Diagnostics.Process。如果你从浏览器应用内部使用这些 API,你会得到一个警告,告诉你这个 API 是不支持的。

总结

net5.0 是为能在任何平台运行的代码而设计的,它结合并取代了 netcoreappnetstandard 名称。我们还有针对特定平台的框架,比如 net5.0-windows(后面还有 net6.0-androidnet6.0-ios)。

由于标准和它的实现之间没有区别,你将能够比使用 .NET Standard 更快地利用新功能。而且由于命名惯例,你将能够很容易地知道谁可以使用一个给定的库–而无需查阅 .NET Standard 版本表。

虽然 .NET Standard 2.1 将是 .NET Standard 的最后一个版本,但 .NET 5 和所有未来的版本将继续支持.NET Standard 2.1 和更早的版本。你应该将 net5.0(以及未来的版本)视为未来共享代码的基础。

祝,编码愉快!


文中相关链接:

[1].https://github.com/dotnet/designs/blob/master/accepted/2020/net5/net5.md

[2].https://github.com/dotnet/standard/tree/master/docs/governance#process

[3].https://dotnet.microsoft.com/platform/dotnet-standard#versions

[4].https://github.com/dotnet/standard/blob/master/docs/faq.md#why-do-you-include-apis-that-dont-work-everywhere

[5].https://devblogs.microsoft.com/dotnet/introducing-net-standard/

[6].https://github.com/dotnet/standard/tree/master/docs/governance#process

[7].https://github.com/dotnet/standard/blob/master/docs/governance/board.md

[8].https://github.com/dotnet/runtime

[9].https://docs.microsoft.com/en-us/dotnet/core/rid-catalog