【譯】如何使庫與本機 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/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發佈。

如有任何疑問,請與我聯繫 ([email protected])

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章