Go to my github

【译】如何使库与本机 AOT 兼容(一)

原文 | Eric Erhardt

翻译 | 郑子铭

本机 AOT 是一种令人兴奋的发布 .NET 应用程序的新方法。多年来,我们听到了 .NET 开发人员的反馈,他们希望他们的应用程序比使用 .NET 构建的传统独立应用程序启动更快、使用更少的内存并且磁盘大小更小。从 .NET 7 开始,我们添加了对将控制台应用程序发布到本机 AOT 的支持,并在 .NET 8 中继续将此功能引入 ASP.NET Core API 应用程序

但这个旅程还没有完成。下一步是让更多令人难以置信的 .NET 生态系统能够在本机 AOT 应用程序中使用。并非所有 .NET 代码都可以在本机 AOT 应用程序中使用。可以使用的 .NET API 存在限制。要获取这些限制的完整列表,请参阅本机 AOT 部署文档,但以下是常见限制的简短列表:

  • 该代码必须是修剪兼容的
    • 没有程序集的动态加载。
    • 可以使用反射,但不支持步行类型图(就像基于反射的序列化器所做的那样)。
  • 运行时不会生成代码,例如 System.Reflection.Emit。

API 在幕后要做什么并不总是显而易见的,因此很难判断哪些 API 可以安全使用,哪些 API 可能会在本机 AOT 应用程序中被破坏。为了解决这个问题,.NET 提供了分析工具,一旦针对 AOT 发布了应用程序,API 可能无法正常工作时,这些分析工具就会向您发出警报。这些工具对于制作与本机 AOT 良好配合的应用程序和库至关重要。

在这篇文章中,我将讨论一些使 .NET 库与本机 AOT 兼容的技巧和策略。许多库不使用有问题的模式并且可以正常工作。其他库已更新为兼容并准备好在 AOT 应用程序中使用。我将使用这些作为案例研究,重点介绍我们在更新 AOT 库时看到的一些常见情况。

警告

最重要的是要知道,.NET 有一组静态分析工具,当它看到经过修剪或本机 AOT 应用程序中可能有问题的代码时,它们会发出警告。这些警告是告诉您什么是有效的、什么是无效的指南。 .NET中修剪和AOT的主要原则是:

如果应用程序在针对 AOT 发布时没有警告,则在 AOT 后它的行为将与没有 AOT 时的行为相同。

这是一个大胆的声明,但我们相信这是获得可接受的开发体验的方法。我们过去尝试过采取大部分有效的方法,但直到您发布应用程序并执行它之后您才会知道。开发人员多次对这些方法感到失望。您需要在发布后执行应用程序中的每个代码路径,这很多时候是不可行的。我不希望任何开发人员经历在将应用程序部署到生产环境后发现它不起作用的情况。

请注意,该原则没有说明应用程序在发布期间确实出现警告时会发生什么情况。它可能有效,也可能无效。没有一种静态可验证的方法来确定会发生什么。在处理这些警告时记住这一点很重要。分析工具发现一些代码无法保证在发布后能够正常工作。发生这种情况时,它会发出警告,告诉您无法保证。

到目前为止,很明显这些警告很重要。我们需要关注他们。

在某些情况下,静态分析工具无法保证某些特定代码能够工作,但在分析自己之后,您决定它能够工作。对于这些情况,可以抑制警告。然而,如果没有确凿的证据,就不应该这样做。禁止对 99% 的时间都有效的代码发出警告违反了上述主要原则。如果应用程序以达到这 1% 情况的方式使用您的库,并且在发布后中断,则会降低没有警告意味着应用程序正常运行的承诺。

分析 .NET 库

我确信你在想“好吧,你说服了我。警告很重要,但我如何获得它们?”。有两种方法可以获取图书馆的警告。

罗斯林分析仪

这些分析仪的工作方式与任何其他 Roslyn 分析仪一样。一旦启用,它们会在构建过程中产生警告,并且您会在您最喜欢的编辑器中看到波形曲线。这些非常适合快速提醒您出现问题,有些甚至还附带代码修复程序。

使用 .NET 8+ SDK 时,您可以在库的 .csproj 中(或在存储库中所有项目的 Directory.Build.props 文件中)设置以下内容:

<PropertyGroup>
  <IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net7.0'))">true</IsAotCompatible>
</PropertyGroup>

这一属性将启用三个底层 Roslyn 分析器:

  • 启用修剪分析器
  • 启用单文件分析器
  • 启用Aot分析器

您可能会注意到上述 MSBuild 设置中的 Condition。这是必要的,因为 Roslyn 分析器根据您的库调用的 API 上的属性进行工作。在 .NET 7 之前,System.* API 没有使用必要的属性进行注释。因此,当您针对 netstandard2.0 甚至 net6.0 进行构建时,Roslyn 分析器无法向您提供正确的警告。如果您尝试为不具备必要属性的 TargetFramework 启用 Roslyn 分析器,该工具会向您发出警告。请注意,如果您的库仅针对 net7.0 及更高版本,则可以删除此条件。

这些分析器的缺点是它们无法像实际的 AOT 编译器那样分析整个程序。虽然它们捕获了大部分警告,但它们仅限于可以生成的警告,并且不能保证是完整的警告。例如,如果您的库依赖于另一个未进行修剪注释的库,则 Roslyn 分析器无法查看另一个库的实现。为了保证您收到所有警告,需要第二种方法。

发布 AOT 测试应用程序

您可以使用 .NET 的 AOT 编译器来分析您的库并生成警告。这种方法比使用 Roslyn 分析器需要更多工作,并且它不会像 Roslyn 分析器那样在 IDE 中提供即时反馈,但它确实保证找到所有警告。根据我的经验,同时启用两者可以让您两全其美。

请注意,如果您碰巧无法在库中定位 net7.0 或更高版本,也可以使用此方法。

可以在准备用于修剪文档的 .NET 库中找到采用此方法的分步指南。唯一的区别是,您不是在测试项目中设置 true,而是设置 true

这里的高级想法是 AOT 发布一个引用您的库的虚拟应用程序。但随后还要告诉 AOT 编译器保留整个库(即,就像所有代码都由应用程序调用并且不能被删除一样)。这会导致 AOT 编译器分析库中的每个方法和类型,并为您提供完整的警告集。

为了确保您的库保持无警告状态,最好将其挂起以在您对库进行更改(例如修复错误或添加新 API)时自动运行。有很多方法可以做到这一点,但似乎效果很好的方法是:

  • 按照上述步骤将 AotCompatibility.TestApp.csproj 添加到您的存储库中。
  • 创建一个脚本来发布测试应用程序并确保发出预期数量的警告(最好为零)。
  • 创建一个在 PR 期间运行脚本的 GitHub 工作流程。

我们的许多与 AOT 兼容的库都采用了这种方法。以下是 OpenTelemetry 存储库中使用此方法的示例:

从该代码中可以看出,OpenTelemetry 团队决定添加一个步骤来执行已发布的应用程序并确保它返回预期的结果代码。测试应用程序在运行时会执行一些库 API,如果 API 无法正常工作,则返回失败退出代码。这样做的优点是可以在实际的 AOT 发布的应用程序中测试库的代码。在 OpenTelemetry 中完成此操作的原因是需要在库中抑制 AOT 警告。这些测试确保抑制警告是有效的,并且代码将来不会被破坏。在抑制警告时,进行这样的测试至关重要,因为静态分析工具无法再完成其工作。

解决警告

属性

现在我们可以在库中看到警告,是时候开始修复它们了。修复警告的常见方法是对代码进行归因,以便为工具提供更多信息。您可以在准备库中从修剪AOT 警告文档中找到使用这些属性的完整指南。从高层次来看,需要了解的主要内容是:

  1. [需要未引用代码]
    • 此属性告诉工具当前方法/类型与修剪不兼容。这使得工具不会警告此方法内部的调用,而是将警告移动到调用此方法的任何代码。
  2. [需要动态代码]
    • 与上面的 RequiresUnreferencedCode 类似,但该 API 不是与修剪过的应用程序不兼容,而是与 AOT 的应用程序不兼容。例如,如果该方法显式调用 System.Reflection.Emit。
  3. [动态访问的成员]
    • 此属性可以应用于类型参数,以指示工具有关将在类型上执行的反射类型。该工具可以使用此信息来确保保留成员,以便反射代码在发布后不会失败。

前两个属性对于标记未设计用于修剪或 AOT 的 API 非常有用。当您的库的用户调用这些不兼容的 API 时,他们将在代码中收到警告,而不是从库内部看到警告。这会通知调用者该 API 将无法工作,并且调用者需要自行解决该警告 - 通常是通过查找兼容的不同 API 来解决。

鉴于您的某些 API 可能永远无法与修剪和 AOT 兼容,因此可能有必要设计兼容的新 API。这正是 System.Text.Json 的 JsonSerializer 在更新以支持修剪和 AOT 时所做的事情。现有的基于反射的 API 都标记为 [RequiresUnreferencedCode] 和 [RequiresDynamicCode]。然后添加了采用 JsonTypeInfo 参数的新 API,这消除了 JsonSerializer 执行反射的需要。这些新的 API 在 AOT 的应用程序中工作,调用者不会因调用它们而收到任何警告。

[DynamicallyAccessedMembers] 属性理解起来有点复杂。用一个例子来解释是最容易的。假设我们有一个如下所示的方法:

public static object CreateNewObject(Type t)
{
    return Activator.CreateInstance(t);
}

此方法将产生警告:

warning IL2067: 'type' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicParameterlessConstructor' in call to 'System.Activator.CreateInstance(Type)'.
The parameter 't' of method 'CreateNewObject(Type)' does not have matching annotations. The source value must declare at least the same requirements as those declared on the target location it is assigned to.

出现此警告是因为为了创建新对象,Activator.CreateInstance 需要调用 Type t 上的无参数构造函数。但是,该工具并不静态地知道哪些类型将被传递到 CreateNewObject 中,因此无法保证它不会修剪应用程序工作所需的构造函数。

为了解决此警告,我们可以使用 [DynamicallyAccessedMembers] 属性。从上面的警告中我们可以看到,如果我们查看 Activator.CreateInstance 的代码,它的 Type 参数上有一个 [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] 属性。我们只需在 CreateNewObject 方法上应用相同的属性,警告就会消失。

public static object CreateNewObject([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type t)
{
    return Activator.CreateInstance(t);
}

这样做现在可以在您的库中引入新的警告,这些警告会调用传入 Type 参数的 CreateNewObject。这些调用站点也需要进行归因,一直递归直到:

  1. 传入静态已知类型(例如 typeof(Customer))。
  2. 类型来自消费者传入的库中的公共 API。

一旦工具发现将使用静态类型,它就会知道它不应该从该类型中修剪构造函数。这使得反射使用即使在应用程序发布后也能正常工作。

这说明从库的最低层(或库集中的最低层)开始注释非常重要。并且还要确保您的所有依赖项在使您的库兼容之前已经与 AOT 兼容。当在较低层添加这些属性时,将导致较高层开始弹出警告。如果您认为已经完成了更高层的工作,这可能会令人沮丧。

目标框架

现在我们已经掌握了如何使用新属性来解决库中的警告,您很可能会遇到问题。这些属性直到最近才存在(大多数属性是 .NET 5,而 RequiresDynamicCode 是 .NET 7)。很可能,由于您正在开发一个库,因此您将针对创建这些属性之前存在的框架。当你这样做时,你会看到:

error CS0246: The type or namespace name 'DynamicallyAccessedMembersAttribute' could not be found (are you missing a using directive or an assembly reference?)

这是这些属性的常见问题。如果它们不存在于我们需要为其构建库的所有 TFM 中,我们如何才能瞄准它们?

我确信您的第一个想法是“为什么 .NET 团队不在面向 netstandard2.0 的 NuGet 包中提供这些属性?这样我就可以使用我的库支持的所有 TFM 上的属性了?”答案是因为这些属性特定于修剪和 AOT 功能,仅在 .NET 5+(用于修剪)和 .NET 7+(用于 AOT)上受支持。如果说这些属性在 .NET Framework 上不起作用,那么它们在 netstandard2.0 上受支持,这将是一条不一致的消息。这与 .NET Core 3.0 中引入的可空属性具有相同的情况和消息。

所以,我们能做些什么?有两种可行的方法,根据您的喜好,您可以选择其中一种。我见过团队使用每种方法都取得了成功,但每种方法都存在一些小缺点。

方法一:#if

第一种方法是确保所有库都以 net7.0+ 为目标(最好是 net8.0,因为它在 System.* API 上具有最新的注释)。然后您可以在属性用法周围使用#if 指令。当您的库为早期 TFM(如 netstandard2.0)构建时,不会引用属性。当它为更新的 .NET 目标构建时,它们就是这样。因此,使用上面的示例,我们可以说:

    public static object CreateNewObject(
#if NET5_0_OR_GREATER
        [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
#endif
        Type t)
    {
        return Activator.CreateInstance(t);
    }

这使得我们的库能够成功构建 netstandard2.0 和 net8.0。请记住,您的库的 netstandard2.0 版本不会包含这些属性。因此,如果消费者在针对早期框架(例如 net7.0)的应用程序中使用您的库并希望 AOT 他们的应用程序,则这些属性将不存在,并且他们将在发布期间从您的库内部收到警告。

使用此方法的另一个考虑因素是当您在多个项目之间共享源文件时。如果我们的 CreateNewObject 方法是在编译为两个项目的文件中定义的,一个项目具有 netstandard2.0;net8.0,另一个项目具有 netstandard2.0;netstandard2.1,第二个库不会在其任何构建中获取属性。这很容易被忽略,特别是当仅使用 Roslyn 分析器查找警告时。

您可能会看到这种方法的另一个缺点。根据您需要应用这些属性的频率,使用 #if 会降低代码的可读性。如果您不小心或为客户可能使用的所有 TFM 进行构建,您也可能会错过 TFM。鉴于这些缺点,可以采用另一种方法。

方法2:内部定义属性

修剪和 AOT 工具通过名称和命名空间尊重这些属性,但不关心属性是在哪个程序集中定义的。这意味着您的库可以定义属性本身,并且工具将尊重它。这种方法最初需要更多的工作,但一旦到位,就不再需要维护。

要采用这种方法,您可以将这些属性的定义复制到共享文件夹中的存储库中,然后将它们包含在需要与 AOT 兼容的每个项目中。这将允许您的库针对任何 TargetFramework 进行构建,并且将始终应用这些属性。如果当前 TargetFramework 中不存在该属性,则共享文件将定义它,并且该属性的副本将发送到您的库中。或者,您可以使用 PolySharp NuGet 包,它在构建时根据需要生成属性定义。

当您使用此方法时,您可以在 AOT 应用程序中使用没有 net7.0+ 目标的库。该库仍将带有必要的属性注释。您可以按照上面的为 AOT 发布测试应用程序部分验证您的库是否兼容。我仍然建议使用 Roslyn 分析器,这意味着以 net8.0 为目标,因为它们提供了便利性和开发人员生产力。

实例探究

现在您已做好在库中查找和解决警告的准备,接下来就可以开始享受乐趣了:实际进行必要的更改。不幸的是,这就是很难提供指导的地方,因为您需要对代码进行的更改完全取决于您的代码正在执行的操作。如果您的库不使用任何不兼容的 API,您将不会收到任何警告,并且可以声明您的库 AOT 兼容。如果您确实收到警告,则需要进行修改以确保您的库可以在 AOT 的应用程序中使用。

官方文档中有一组建议,这是一个很好的起点。这些一般准则对需要进行的更改进行了简要、高层次的总结。

我们编制了一份对实际库所做的更改列表,以使它们与 AOT 兼容。这并不是所有可能解决方案的详尽列表,但它们是我们遇到的一些常见解决方案。希望他们可以帮助您入门。对于您无法解决的新情况,请随时联系并寻求帮助。

Microsoft.IdentityModel.JsonWebTokens

Microsoft.IdentityModel.* 库集用于解析和验证 ASP.NET Core 应用程序中的 JSON Web 令牌 (JWT)。经过对警告的初步调查,发现有两类问题:一类是微不足道的,一类是极其困难的。

首先,简单的 AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet#2042 与之前讨论的 Activator.CreateInstance 的情况相同。类型参数(在本例中为泛型类型参数)被传入名为 Activator.CreateInstance 的方法。该参数需要用[DynamicallyAccessedMembers]进行注释并向上飞行,直到传入静态类型。

第二个问题需要更多的改变和更多的时间来实现。 IdentityModel 库使用 Newtonsoft.Json(嗯,Newtonsoft.Json 的私有分支 - 但这是另一天的故事)来解析和创建 JSON 有效负载。 Newtonsoft.Json 早在 .NET 中考虑修剪和 AOT 之前就创建了,因此它的设计并不是为了兼容。随着近年来可用于 AOT 应用程序的 System.Text.Json 的推出,以及所需的工作量,Newtonsoft.Json 不太可能与本机 AOT 兼容。

这意味着只有一种方法可以解决其余警告:AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet#2042 将库从 Newtonsoft.Json 迁移到 System.Text.Json。在此过程中,性能也得到了改进——例如使用 Utf8JsonReader/Writer 而不是使用对象序列化。最后,该库现在速度更快并且与 AOT 兼容。由于该库被用于如此多的应用程序,因此这些结果非常值得投资。

StackExchange.Redis

StackExchange.Redis 库是一个流行的 .NET 库,用于与 Redis 数据存储交互。对警告进行初步调查后,库本身存在一些警告,并且其依赖项之一存在一些问题。

让我们从依赖关系开始,因为最好首先解决最低层的警告。 mgravell/Pipelines.Sockets.Unofficial#73 跳过一些使用不兼容 API 的优化。

此代码使用 System.Reflection.Emit 生成 IL 以从对象中读取字段的值。这样做是出于性能原因,因为使用正常反射比仅读取字段慢。但是,在本机 AOT 应用程序中,此代码将失败,因为没有 JIT 编译器将 IL 编译为机器代码。为了解决这个问题,添加了对 RuntimeFeature.IsDynamicCodeSupported 的检查,当当前运行时允许生成动态代码时返回 true,否则返回 false。对于本机 AOT,这始终为 false,并且会跳过 DynamicMethod 代码。始终使用正常反射的回退。

退后一步,看看代码试图完成的任务,我们可以看到它使用了针对 MulticastDelegate(系统命名空间中定义的核心类型)的私有反射。它尝试访问私有字段以枚举调用列表,但不分配数组。更深入地看,此代码访问的字段甚至不存在于本机 AOT 版本的 MulticastDelegate 上。当字段不存在时,将采取后备措施,最终分配一个数组。这里正确的长期解决方案是引入一个新的运行时 API,用于免分配的调用枚举

另一个不兼容的优化如下:

此代码使用反射在运行时填充泛型类型,然后从结果类型中获取静态属性。由于泛型和值类型(即结构)的工作方式,在静态未知类型上调用 MakeGenericType 与 AOT 不兼容。 .NET 运行时为具有值类型的泛型类型的每个实例生成专门的代码。如果没有提前为特定值类型(如 int 或 float)生成专用代码,.NET AOT 运行时将失败,因为它无法动态生成它。修复方法与上面相同,在 AOT 应用程序中运行时跳过此优化,从而消除警告。

您可能已经发现现有代码的一个问题:反射调用的结果没有被返回。 mgravell/Pipelines.Sockets.Unofficial#74 跟进解决了调查这些 AOT 警告时发现的问题。这里使用反射的原因是因为 PinnedArrayPoolAllocator 有一个通用约束,即 T 需要是非托管类型。此代码需要采用不受约束的 T 并在 T :不受管理时桥接约束。使用反射是目前桥接此类通用约束的唯一方法。后续操作消除了对通用约束的需要,并且 mgravell/Pipelines.Sockets.Unofficial#78 能够删除本机 AOT 的特殊外壳。这是一个很好的结果,因为现在 AOT 和非 AOT 应用程序中都使用相同的代码。

回到 StackExchange.Redis 库,StackExchange/StackExchange.Redis#2451 解决了其代码的两个主要问题。

此代码使用 System.Threading.Channels.Channel 并尝试获取 Channel 中的项目计数。当编写原始代码时,ChannelReader 不包含 Count 属性。它是在后来的版本中添加的。所以这段代码选择使用私有反射来获取值。由于 _queue.GetType() 不是静态已知类型(它将是 Channel 的派生类型之一),因此此反射与修剪不兼容。这里的解决方法是利用新的 CanCount 和 Count API(如果可用)(它们位于支持修剪和 AOT 的 .NET 版本中),并在不支持时继续使用反射。

其次,使用反射的方法中出现了一些警告。

此更改表明某些反射用法可以静态验证,而另一些则不能。以前,代码循环遍历应用程序中的所有程序集,检查具有特定名称的程序集,然后按名称查找类型并检索该类型的属性值。此代码引发了一些修剪警告,因为根据设计,修剪将删除它在应用程序中静态使用的程序集和类型。通过一点点重写,特别是使用具有常量、完全限定类型名称的 Type.GetType,该工具就能够静态地了解正在处理哪些类型。如果找到这些类型,该工具将保留这些类型的必要成员。因此,该工具不再发出警告,并且代码现在是兼容的。

在撰写本文时,StackExchange.Redis 库中的最后一组警告尚未得到解决。该库具有评估 LuaScript 的能力,这本身不是问题。问题在于脚本参数的传递方式。以文档中的示例为例:

const string Script = "redis.call('set', @key, @value)";

using (ConnectionMultiplexer conn = /* init code */)
{
    var db = conn.GetDatabase();

    var prepared = LuaScript.Prepare(Script);
    db.ScriptEvaluate(prepared, new { key = (RedisKey)"mykey", value = 123 });
}

您可以看到 db.ScriptEvaluate 方法接受要评估的脚本和一个对象(在本例中为匿名类型),其中对象的属性映射到脚本中的参数。 StackExchange.Redis 使用反射来获取属性的值并将值传递到服务器。在这种情况下,API 的设计不兼容修剪。之所以不安全,是因为API接受一个对象parameters参数,然后调用parameters.GetType()来获取该对象的属性。该类型不是静态已知的,因为它可以是任何类型的任何对象。该工具并不静态地知道可以传递到此方法中的所有类型。

这里的解决方案是用 [RequiresUnreferencedCode] 标记现有的 db.ScriptEvaluate 方法,这将警告任何调用者该方法不兼容。然后可以选择添加一个新的 API,该 API 旨在与修剪兼容。兼容 API 的一种选择可能是:

// existing
RedisResult ScriptEvaluate(LuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None);

// potential new method
RedisResult ScriptEvaluate<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TParameters>(LuaScript script, TParameters? parameters = null, CommandFlags flags = CommandFlags.None);

新方法将使用 typeof(TParameters) 来获取对象的属性,而不是调用parameters.GetType()。这将允许修剪工具准确地查看哪些类型被传递到此方法中。并且工具将保留必要的成员,以便在修剪后使反射起作用。如果参数的实际类型是从 TParameters 派生的,则该方法将仅使用 TParameters 上定义的属性。派生类型的属性将不可见。这使得修剪前后的行为保持一致。

原文链接

How to make libraries compatible with native AOT

知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

如有任何疑问,请与我联系 (MingsonZheng@outlook.com)

posted @ 2024-02-17 08:01  郑子铭  阅读(27)  评论(0编辑  收藏  举报