该页面可以包含自动翻译的文本。

如何在 .NET 中开发 Native AOT 应用程序

.NET 8 全面支持提前编译,也称为 Native AOT。此类应用程序通常比托管解决方案更快,消耗的内存更少。

.NET AOT 发布可生成精简的、自包含的应用程序。此类应用程序:

  1. 可以在未安装 .NET 运行时的机器上运行。
  2. 针对特定的运行时环境,例如 Windows x64。
  3. 不包含未使用的代码。

部署 .NET AOT 应用程序

我们开发了用于 PDF 处理的 Docotic.Pdf 库。AOT 兼容性是 2024 年最受欢迎的功能请求之一。本文介绍了我们实现 AOT 兼容性的历程。您将学习如何在 .NET 项目中查找和修复 AOT 问题。

.NET AOT 发布基础知识

要发布 Native AOT 应用程序,请将以下属性添加到您的项目文件中:

<PropertyGroup>
    <PublishAot>true</PublishAot>
</PropertyGroup>

您可能还需要安装一些 先决条件。然后,您可以从 Visual Studio 发布您的应用程序。或者,您可以使用 dotnet publish 命令从命令行发布项目:

dotnet publish -r linux-x64 -c Release

.NET 库的 AOT 兼容性

Native AOT 发布功能在 .NET 7 和 .NET 8 中可用。在 .NET 7 中,您只能发布控制台应用程序。在 .NET 8 中,您还可以发布 ASP.NET Core 应用程序。

但是 Docotic.Pdf 核心库面向 .NET Standard 2.0。我们可以让它对 AOT 友好吗?当然。

您仍然可以在针对 .NET Standard、.NET 5 或 .NET 6 的 .NET 库中查找和修复 AOT 兼容性问题。Native AOT 应用程序可以依赖该库。

查找 AOT 问题

我们关于 AOT 兼容性的第一个支持请求听起来像这样:

我们正在考虑使用编译为 WebAssembly 的 Docotic.Pdf。当我们将其编译为 AOT 时,它会生成修剪警告:
警告 IL2104:程序集“BitMiracle.Docotic.Pdf”产生了修剪警告。有关更多信息,请参阅 https://aka.ms/dotnet-illink/libraries

信息量不大。让我们重现该问题并获取有关这些修剪警告的更多详细信息。

获取发布警告

发布测试应用程序是查找 .NET AOT 问题的最灵活方法。您需要:

  1. 使用 PublishAot = true 创建一个 .NET 8 控制台项目
  2. 添加对项目的引用并使用其某些类型

以下是我们用于 Docotic.Pdf 的 csproj 配置:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <PublishAot>true</PublishAot>
        <TrimmerSingleWarn>false</TrimmerSingleWarn>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        <WarningsAsErrors />
    </PropertyGroup>

    <ItemGroup>
        <ProjectReference Include="..\Docotic\Docotic.csproj" />
    </ItemGroup>
</Project>

TrimmerSingleWarn = false 允许您获取有关警告的详细信息。否则,您只会收到“程序集‘X’产生了修剪警告”消息。

以下是 Program.cs 中的 C# 代码:

using BitMiracle.Docotic.Pdf;

using var pdf = new PdfDocument();

添加一些代码以强制加载正在检查的程序集。无需覆盖项目的所有功能即可获得发布警告。使用单一类型就足够了。但您需要编写更多代码来捕获运行时错误

该项目的 dotnet publish -r win-x64 -c Release 命令返回了修剪和 AOT 警告。以下是列表的简短版本:

AOT 分析警告 IL3050: Org.BouncyCastle.Utilities.Enums.GetEnumValues(Type): 使用具有“RequiresDynamicCodeAttribute”的成员“System.Enum.GetValues(Type)”可能会在 AOT 编译时破坏功能。

修剪分析警告 IL2026: LogProviders.Log4NetLogProvider.Log4NetLogger.GetCreateLoggingEvent(ParameterExpression,UnaryExpression,ParameterExpression,UnaryExpression,Type): 使用具有“RequiresUnreferencedCodeAttribute”的成员“System.Linq.Expressions.Expression.Property(Expression,String)”可能会在修剪应用程序代码时破坏功能。

修剪分析警告 IL2057: LogProviders.LogProviderBase.FindType(String,String[]): 传递给方法“System.Type.GetType(String)”的参数“typeName”的值无法识别。

修剪分析警告 IL2070: LogProviders.NLogLogProvider.NLogLogger.GetIsEnabledDelegate(Type,String): 在调用“System.Type.GetProperty(String)”时,“this”参数不满足“DynamicallyAccessedMemberTypes.PublicProperties”。方法“LogProviders.NLogLogProvider.NLogLogger.GetIsEnabledDelegate(Type,String)”的参数“loggerType”没有匹配的注释。源值必须至少声明与其分配到的目标位置上声明的要求相同的要求。

修剪分析警告 IL2075: LogProviders.Log4NetLogProvider.GetOpenNdcMethod(): 在调用“System.Type.GetProperty(String)”时,“this”参数不满足“DynamicallyAccessedMemberTypes.PublicProperties”。方法“LogProviders.LogProviderBase.FindType(String,String)”的返回值没有匹配的注释。源值必须至少声明与其分配到的目标位置上声明的要求相同的要求。

我们找到了根本原因。在修复问题之前,让我们检查一下查找 AOT 兼容性问题的替代选项。

使用 AOT 兼容性分析器

Microsoft 提供了 Roslyn 分析器来查找 .NET 7 或 .NET 8 项目中的 AOT 问题。它们可以在您编写代码时突出 显示 AOT 问题。但发布测试应用程序会检测到更多问题。

要使用静态分析,请将以下属性添加到 .csproj 文件:

<PropertyGroup>
    <IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

您需要以 .NET 7 或 .NET 8 框架为目标,才能在 .NET Standard 2.0 项目中使用此类分析。项目配置可能如下所示:

<PropertyGroup>
    <TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
    <IsAotCompatible Condition="'$(TargetFramework)'=='net8.0'">true</IsAotCompatible>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <WarningsAsErrors />
</PropertyGroup>

我们将上述配置应用于我们的大型核心项目,并收到 1036 个构建错误。

许多构建错误

它们都与 AOT 兼容性有关吗?我们重建了没有 IsAotCompatible 属性的项目。但仍然有 1036 个错误。

所有错误都与 .NET 8 相对于 .NET Standard 2.0 的改进有关。在我们的案例中,大多数问题都与对可空引用类型的更好支持有关。还有许多关于新的 ArgumentOutOfRangeException.ThrowIfLessThanArgumentNullException.ThrowIfNull 辅助方法的 CA1500 和 CA1512 错误。

我们修复了所有错误并启用了 IsAotCompatible 属性。项目构建完成时没有出现 AOT 错误。静态分析没有捕获发布过程中发现的任何警告 🙁

为了确认,我们在代码库中添加了以下测试代码:

Type t = typeof(int);
t = typeof(List<>).MakeGenericType(t);
Console.WriteLine(Activator.CreateInstance(t));

string s = Console.ReadLine() ?? string.Empty;
Type? type = Type.GetType(s);
if (type != null)
{
    foreach (var m in type.GetMethods())
        Console.WriteLine(m.Name);
}

此 C# 代码与 .NET AOT 和修剪完全不兼容。静态分析发现了以下错误:

错误 IL3050:使用具有“RequiresDynamicCodeAttribute”的成员“System.Type.MakeGenericType(params Type[])”可能会在 AOT 编译时破坏功能。此实例化的 本机 代码可能在运行时不可用。

错误 IL2057:传递给方法“System.Type.GetType(String)”的参数“typeName”的值无法识别。无法保证目标类型的可用性。

因此,Roslyn 分析器可以工作并发现一些问题。在我们的案例中,他们没有发现任何问题。

捕获运行时错误

如何使库与 Native AOT 兼容 文章指出了以下原则:

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

在实践中,AOT 之后的版本仍然可以以不同的方式工作。我们已经修复了所有 AOT 和修剪警告。所有自动测试都通过了。测试应用程序正确处理了 PDF 文档。而同一应用程序的 AOT 版本生成了错误的文档。

AOT 应用程序仍然无法工作

AOT 发布错误地删除了一些必需的代码。我们创建了 优化删除了必要的代码 问题。.NET 团队迅速确认并修复了它。我们在 C# 代码中添加了一种解决方法,直到修复可用。

这个故事的寓意是,发布和静态分析警告可能还不够。在测试应用程序中使用项目的关键功能。并在部署后运行测试应用程序。

Docotic.Pdf 测试应用程序使用 PDF 压缩、文本提取和其他关键功能。我们这样使用它:

AotCompatibility.exe compress "C:\compressed.pdf" "C:\input.pdf" "optional-password"

手动测试已发布的应用程序并不方便。值得将这个过程自动化。

自动检测 AOT 问题

Docotic.Pdf 开发基于 自动测试。每个功能或错误都有测试。.NET AOT 兼容性也不例外。

有两种方法可以自动化测试 Native AOT 应用程序。

将测试置于 AOT 应用程序之外

在这里,您使用常规的 NUnit/xUnit/MSTest 项目。在构建此项目之前,您发布 AOT 测试应用程序。测试使用 Process.Start 方法运行已发布的应用程序。

主要优势是将测试基础架构与 AOT 应用程序分离。您可以使用单元测试框架的全部功能。测试框架中的任何 AOT 兼容性问题都无关紧要。重用现有测试用例也更容易。

测试项目的示例配置 可在 GitHub 上找到。Docotic.Tests 项目包含针对托管和 AOT 版本的自动测试。AotCompatibility 是用于 AOT 发布的测试应用程序。

AotCompatibility.csprojRelease 配置中的构建后事件上发布测试应用程序。当您使用 Visual Studio 中的“发布”向导时,'$(PublishProtocol)'=='' 条件会禁用此步骤。

<Target Name="PostBuild" AfterTargets="PostBuildEvent" Condition="'$(Configuration)' == 'Release' And '$(PublishProtocol)'==''">
    <Exec Command="&quot;$(SolutionDir)Scripts\publish.bat&quot;" />
</Target>

publish.bat 脚本很简单:

@echo off

SET AOT_TEST_DIR=%~dp0\..\AotCompatibility
dotnet publish "%TRIM_TEST_DIR%\AotCompatibility.csproj" --no-build /warnaserror /p:PublishProfile="%AOT_TEST_DIR%\Properties\PublishProfiles\win-x64-installer.pubxml"

请注意,我们在这里使用了 --no-build 参数。该项目在构建后事件之前已经构建。我们不需要再次构建它。

NativeAot.cs 中的测试使用不同的参数运行已发布的 AotCompatibility.exeDocotic.TestsAotCompatibility 项目共享相同的测试代码。这使我们能够检查托管和 AOT 版本而无需重复代码。

将测试放在 AOT 应用程序内

另一种方法是在 AOT 测试应用程序内编写测试。2024 年 7 月,您只能使用 MS Test 早期预览版。阅读 测试您的 Native AOT 应用程序 文章了解更多详情。

关键问题是,测试框架中的任何 AOT 问题都会使您的测试不可靠。而且只有一组有限的测试框架功能可用。这就是为什么我们更喜欢将测试基础架构放在 AOT 应用程序之外。

修复 AOT 和修剪警告

避免抑制发布警告。通常,抑制是一种不正确的解决方案。当您抑制 AOT 或修剪警告时,您说您的项目与 AOT 兼容。但是,警告的根本原因仍然存在。任何被抑制的警告最终都可能导致应用程序运行时失败。

尝试删除或重写与每个警告相关的代码。更新第三方组件也可能有帮助。

让我们回顾一下我们如何修复 Docotic.Pdf 中的 AOT 和修剪警告。您已经在 获取发布警告 部分中看到了其中一些。

从 LibLog 中修剪警告

我们使用 LibLog 库进行日志记录。LibLog 依靠反射来检测和使用流行的日志记录框架。它在设计上与 AOT 不兼容。

目前,Microsoft.Extensions.Logging 包是 .NET 中日志记录的标准。LibLog 开发现已冻结。

鉴于此,我们已将 LibLog 从代码中完全移除。相反,我们发布了 Docotic.Pdf.Logging 插件。它依赖于 Microsoft.Extensions.Logging.Abstractions 接口。这减少了核心库的大小并修复了所有相关的修剪警告。

无修剪警告

Docotic.Layout 附加组件中的调试代码

Docotic.Layout 附加组件 中,我们使用反射进行内部调试。这导致了警告 IL2075。

代码库中存在相应的代码,但客户端无法通过公共 API 使用它。解决方案是从 Release 配置中排除代码。它现在仅在 Debug 配置中使用。

BouncyCastle 的 AOT 警告 IL3050

BouncyCastle 库可帮助我们签署 PDF 文档。以下警告来自 BouncyCastle 代码:

Org.BouncyCastle.Utilities.Enums.GetEnumValues(Type): 使用具有“RequiresDynamicCodeAttribute”的成员“System.Enum.GetValues(Type)”可能会在 AOT 编译时破坏功能。可能无法在运行时创建枚举类型的数组。请改用 GetValues<TEnum> 重载或 GetValuesAsUnderlyingType 方法。

BouncyCastle 使用 Enum.GetValues(Type) 方法,该方法不兼容 AOT。

最简单的解决方案是改用 Enum.GetValues<T>() 重载。最新的 BouncyCastle 版本已经使用它了。不幸的是,此重载仅在 .NET 5 或更新版本中可用。这不是 .NET Standard 2.0 的选项。

我们深入挖掘并分析了 BouncyCastle 代码。结果发现 BouncyCastle 使用它来防止枚举常量的混淆。相反,我们使用 [System.Reflection.Obfuscation(Exclude = true)] 属性禁用了相应枚举的混淆。并删除了不再需要使用的 Enum.GetValues(Type)

混淆构建中的一个奇怪问题

我们使用混淆来保护生产版本。令人惊讶的是,混淆版本的 .NET AOT 部署返回了类似的警告:

<unknown>:0: 错误:符号“__GenericLookupFromType_BitMiracle_Docotic_Pdf___4<System___Canon__System___Canon__System___Canon__Int32>_TypeHandle___System___Canon”已定义

此类错误没有关联的 IL30##IL2### 代码。不清楚如何识别关联的代码。

错误只发生在混淆版本中。这可能是混淆器中的一个错误。我们已经更新了混淆器,但错误仍然存​​在。

我们需要缩小问题的范围。我们使用了以下基于二分查找的过程:

  1. 禁用一半命名空间的混淆。检查是否发生错误。继续,直到找到导致错误的命名空间。
  2. 禁用第一步中命名空间中一半类型的混淆。检查是否发生错误。继续,直到找到导致错误的类型。
  3. 禁用第二步中类型中一半成员的混淆。检查是否发生错误。继续,直到找到导致错误的成员。
  4. 查看第三步中成员的代码。注释掉不寻常的部分并检查是否发生错误。

我们最终进入了第一步 🙂 在禁用 所有 命名空间的混淆后,仍然会出现错误。

我们用 ILSpy 分析了生成的程序集。发现了一些意外的编译器生成类型。ILSpy 显示了这些类型的用法,这些类型来自这样的 C# 代码:

interface X
{
    void f();
}

X obj = ..;
if (obj.f != null)
    ..

这个奇怪的代码来自 将旧式 C 代码迁移到 C#if (obj.f != null) 条件完全是多余的。我们删除了这些条件,错误就消失了。

不要使用 GetCallingAssembly

我们发现了另一个 运行时问题。AOT 发布后,LicenseManager.AddLicenseData(string) 方法调用失败,并出现以下错误:

Assembly.GetCallingAssembly() 抛出 System.PlatformNotSupportedException:此平台不支持操作。

.NET 9 中的 Assembly.GetCallingAssembly 方法 应该为 Native AOT 实现。在修复完成之前,我们重构了代码以尽量减少负面影响。现在,我们仅使用调用程序集属性来验证 Application 许可证。其他许可证类型与 AOT 兼容。

使用特殊属性标记代码

幸运的是,我们已经能够修复核心 Docotic.Pdf 库和大多数附加组件中的所有 AOT 和修剪警告。但是,您可能无法重写所有 AOT 不兼容的代码。

.NET 为这种情况提供了特殊属性。您可以使用属性标记 API,以告知客户端已知的 AOT 问题。您的 API 的用户在调用标记的方法时会收到警告。

标记 AOT 不兼容 .NET 代码的关键属性是:

  • RequiresDynamicCode
  • RequiresUnreferencedCode
  • DynamicallyAccessedMembers

您可以在官方的 AOT 警告简介为修剪准备 .NET 库 文章中找到有关这些属性的更多信息。

结论

Native AOT 部署是 .NET 世界中的一大进步。您可以编写常规 C# 代码并获得本机应用程序或库。此类应用程序通常速度更快,无需安装 .NET 运行时即可运行。您甚至可以使用 C、Rust 或其他非 .NET 编程语言中发布的 DLL。

发布测试 .NET 8 应用程序以查找 AOT 兼容性问题。还有 Roslyn 分析器,但它们捕获的问题较少。

核心 Docotic.Pdf 库现在兼容 AOT 和修剪。以下附加组件也兼容:

  • Layout 附加组件
  • Gdi 附加组件
  • Logging 附加组件

HTML 转 PDF 插件仍包含修剪警告。这是我们在 Native AOT 路线图上的下一个目标。

欢迎随时向我们询问有关 Native AOT 或 PDF 处理的问题。