該頁面可以包含自動翻譯的文字。

如何在 .NET 中開發 Native AOT 應用程式

.NET 8 為前置編譯,也就是 Native AOT,帶來了完整支援。這類應用程式通常啟動更快,而且比受控方案消耗更少記憶體。

.NET AOT 發佈會產生經過裁剪、可自包含的應用程式。這類應用程式:

  1. 可以在未安裝 .NET 執行階段的機器上執行。
  2. 以特定執行環境為目標,例如 Windows x64。
  3. 不包含未使用的程式碼。

部署 .NET AOT 應用程式

我們開發 Docotic.Pdf 程式庫用於 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 時,它會產生裁剪警告:
warning IL2104: Assembly 'BitMiracle.Docotic.Pdf' produced trim warnings. For more information see 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 可讓你取得警告的詳細資訊。否則,你只會看到「Assembly 'X' produced trim warnings」訊息。

以下是 Program.cs 中的 C# 程式碼:

using BitMiracle.Docotic.Pdf;

using var pdf = new PdfDocument();

加入一些程式碼以強制載入正在檢查的組件。若要取得發佈警告,不需要涵蓋專案的所有功能。使用單一型別就足夠了。不過,若要捕捉執行階段錯誤,你需要撰寫更多程式碼。

此專案執行 dotnet publish -r win-x64 -c Release 命令後回傳裁剪與 AOT 警告。以下是清單的簡短版本:

AOT analysis warning IL3050: Org.BouncyCastle.Utilities.Enums.GetEnumValues(Type): Using member 'System.Enum.GetValues(Type)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling.

Trim analysis warning IL2026: LogProviders.Log4NetLogProvider.Log4NetLogger.GetCreateLoggingEvent(ParameterExpression,UnaryExpression,ParameterExpression,UnaryExpression,Type): Using member 'System.Linq.Expressions.Expression.Property(Expression,String)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.

Trim analysis warning IL2057: LogProviders.LogProviderBase.FindType(String,String[]): Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String)'.

Trim analysis warning IL2070: LogProviders.NLogLogProvider.NLogLogger.GetIsEnabledDelegate(Type,String): 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicProperties' in call to 'System.Type.GetProperty(String)'. The parameter 'loggerType' of method 'LogProviders.NLogLogProvider.NLogLogger.GetIsEnabledDelegate(Type,String)' 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.

Trim analysis warning IL2075: LogProviders.Log4NetLogProvider.GetOpenNdcMethod(): 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicProperties' in call to 'System.Type.GetProperty(String)'. The return value of method 'LogProviders.LogProviderBase.FindType(String,String)' 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.

我們找到了根本原因。在修正這些問題之前,先看看找出 AOT 相容性問題的替代方法。

使用 AOT 相容性分析器

Microsoft 提供 Roslyn 分析器,可在 .NET 7 或 .NET 8 專案中找出 AOT 問題。它們能在你撰寫程式碼時標示出 AOT 問題。不過,發佈測試應用程式能偵測到更多問題。

若要使用靜態分析,請在 .csproj 檔案中加入以下屬性:

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

若要在 .NET Standard 2.0 專案中使用這種分析,你需要以 .NET 7 或 .NET 8 框架為目標。專案設定可能如下所示:

<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 的改進有關。在我們的情況中,大多數問題都與對可為 null 參考型別的更佳支援有關。也有許多 CA1500 與 CA1512 錯誤,關於新的 ArgumentOutOfRangeException.ThrowIfLessThanArgumentNullException.ThrowIfNull 輔助方法。

我們修正了所有錯誤,並啟用了 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 和裁剪 100% 不相容。而靜態分析識別出了以下錯誤:

error IL3050: Using member 'System.Type.MakeGenericType(params Type[])' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. The native code for this instantiation might not be available at runtime.

error IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String)'. It's not possible to guarantee the availability of the target type.

因此,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 開發是以自動化測試為基礎。每個功能或 bug 都有測試。.NET AOT 相容性也不例外。

自動化測試 Native AOT 應用程式有兩種方式。

將測試放在 AOT 應用程式外部

這裡使用一般的 NUnit/xUnit/MSTest 專案。在建置此專案之前,先發佈 AOT 測試應用程式。測試會使用 Process.Start 方法執行已發佈的應用程式。

主要優點是將測試基礎結構與 AOT 應用程式分離。你可以使用單元測試框架的完整功能。測試框架中的任何 AOT 相容性問題都不重要。重用既有測試案例也更容易。

GitHub 上提供了測試專案的範例設定Docotic.Tests 專案包含受控版與 AOT 版的自動化測試。AotCompatibility 是一個用於 AOT 發佈的測試應用程式。

AotCompatibility.csproj 會在 Release 組態的建置後事件中發佈測試應用程式。當你使用 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 附加元件中的偵錯程式碼

版面附加元件中,我們使用反射進行內部偵錯。這導致裁剪分析警告 IL2075。

對應的程式碼存在於程式碼基底中,但客戶無法透過公開 API 使用它。解法是將這段程式碼排除在 Release 組態之外。現在它只在 Debug 組態中使用。

來自 BouncyCastle 的 AOT 警告 IL3050

BouncyCastle 程式庫協助我們簽署 PDF 文件。以下警告來自其程式碼:

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: error: symbol '__GenericLookupFromType_BitMiracle_Docotic_Pdf___4<System___Canon__System___Canon__System___Canon__Int32>_TypeHandle___System___Canon' is already defined

這類錯誤沒有對應的 IL30##IL2### 代碼。要如何識別相關程式碼並不清楚。

錯誤只發生在混淆版本中。這可能是混淆器的 bug。我們更新了混淆器,但錯誤仍然存在。

我們需要縮小問題範圍。我們採用了以下流程,基於二分搜尋:

  1. 停用一半命名空間的混淆。檢查是否發生錯誤。持續進行,直到找到導致錯誤的命名空間。
  2. 停用步驟 1 中命名空間內一半型別的混淆。檢查是否發生錯誤。持續進行,直到找到導致錯誤的型別。
  3. 停用步驟 2 中型別內一半成員的混淆。檢查是否發生錯誤。持續進行,直到找到導致錯誤的成員。
  4. 檢視步驟 3 中成員的程式碼。註解掉不尋常的部分,並檢查是否發生錯誤。

最後我們停在第 1 步 🙂 在停用 所有 命名空間的混淆後,錯誤仍然存在。

我們使用 ILSpy 分析產生的組件。發現了幾個意料之外的編譯器產生型別。ILSpy 顯示了它們在類似 C# 程式碼中的用法:

interface X
{
    void f();
}

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

這段奇怪的程式碼來自將舊式 C 程式碼遷移到 C#if (obj.f != null) 條件完全多餘。我們移除了這類條件後,錯誤就消失了。

不要使用 GetCallingAssembly

我們又捕捉到一個執行階段問題[LicenseManager.AddLicenseData(string)](https://api.docotic.com/licensemanager-addlicensedata#string_) 方法在 AOT 發佈後失敗,並出現以下錯誤:

Assembly.GetCallingAssembly() throws System.PlatformNotSupportedException: Operation is not supported on this platform.

Assembly.GetCallingAssembly 方法應該在 Native AOT 中實作,預計會在 .NET 9 中提供。在修正完成之前,我們重構了程式碼,以將負面影響降到最低。現在,我們只使用呼叫端組件屬性來驗證應用程式授權。其他授權類型與 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 和裁剪相容。以下附加元件也相容:

  • Conformance 附加元件
  • 版面附加元件
  • Gdi 附加元件
  • 記錄附加元件

HTML to PDF 附加元件仍然包含裁剪警告。這是我們在 Native AOT 路線圖上的下一個目標。

歡迎就 Native AOT 或 PDF 處理向我們詢問問題。