▲
點選上方「DotNet NB」
關註公眾號
回 復 「 1 」 獲 取 開 發 者 路 線 圖
學 習 分 享 丨 作 者 / 鄭 子 銘
這 是 D o t N e t N B 公 眾 號 的 第 229 篇 原 創 文 章
原文 | 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 儲存庫中使用此方法的範例:
AotCompatibility.TestApp.csproj
釋出指令碼
GitHub 工作流程
從該程式碼中可以看出,OpenTelemetry 團隊決定添加一個步驟來執行已釋出的應用程式並確保它返回預期的結果程式碼。測試應用程式在執行時會執行一些庫 API,如果 API 無法正常工作,則返回失敗結束程式碼。這樣做的優點是可以在實際的 AOT 釋出的應用程式中測試庫的程式碼。在 OpenTelemetry 中完成此操作的原因是需要在庫中抑制 AOT 警告。這些測試確保抑制警告是有效的,並且程式碼將來不會被破壞。在抑制警告時,進行這樣的測試至關重要,因為靜態分析工具無法再完成其工作。
解決警告
內容
現在我們可以在庫中看到警告,是時候開始修復它們了。修復警告的常見方法是對程式碼進行歸因,以便為工具提供更多資訊。您可以在準備庫中從修剪和 AOT 警告文件中找到使用這些內容的完整指南。從高層次來看,需要了解的主要內容是:
[需要未參照程式碼]
此內容告訴工具當前方法/型別與修剪不相容。這使得工具不會警告此方法
內部的呼叫,而是將警告移動到呼叫此方法的任何程式碼。
[需要動態程式碼]
與上面的 RequiresUnreferencedCode 類似,但該 API 不是與修剪過的
應用程式不相容,而是與 AOT 的應用程式不相容。例如,如果該方法顯式
呼叫 System.Reflection.Emit。
[動態存取的成員]
此內容可以套用於型別參數,以指示工具有關將在型別上執行的反射型別。
該工具可以使用此資訊來確保保留成員,以便反射程式碼在釋出後不會失敗。
前兩個內容對於標記未設計用於修剪或 AOT 的 API 非常有用。當您的庫的使用者呼叫這些不相容的 API 時,他們將在程式碼中收到警告,而不是從庫內部看到警告。這會通知呼叫者該 API 將無法工作,並且呼叫者需要自行解決該警告 - 通常是透過尋找相容的不同 API 來解決。
鑒於您的某些 API 可能永遠無法與修剪和 AOT 相容,因此可能有必要設計相容的新 API。這正是 System.Text.Json 的 JsonSerializer 在更新以支持修剪和 AOT 時所做的事情。現有的基於反射的 API 都標記為 [RequiresUnreferencedCode] 和 [RequiresDynamicCode]。然後添加了采用 JsonTypeInfo 參數的新 API,這消除了 JsonSerializer 執行反射的需要。這些新的 API 在 AOT 的應用程式中工作,呼叫者不會因呼叫它們而收到任何警告。
[DynamicallyAccessedMembers] 內容理解起來有點復雜。用一個例子來解釋是最容易的。假設我們有一個如下所示的方法:
publicstaticobjectCreateNewObject(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 方法上套用相同的內容,警告就會消失。
publicstaticobjectCreateNewObject([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type t)
{
return Activator.CreateInstance(t);
}
這樣做現在可以在您的庫中引入新的警告,這些警告會呼叫傳入 Type 參數的 CreateNewObject。這些呼叫站點也需要進行歸因,一直遞迴直到:
傳入靜態已知型別(例如 typeof(Customer))。
型別來自消費者傳入的庫中的公共 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 目標構建時,它們就是這樣。因此,使用上面的範例,我們可以說:
publicstaticobjectCreateNewObject(
#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 方法是在編譯為兩個計畫的檔中定義的,一個計畫具有
您可能會看到這種方法的另一個缺點。根據您需要套用這些內容的頻率,使用 #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
回到 StackExchange.Redis 庫,StackExchange/StackExchange.Redis#2451 解決了其程式碼的兩個主要問題。
此程式碼使用 System.Threading.Channels.Channel
其次,使用反射的方法中出現了一些警告。
此更改表明某些反射用法可以靜態驗證,而另一些則不能。以前,程式碼迴圈遍歷應用程式中的所有程式集,檢查具有特定名稱的程式集,然後按名稱尋找型別並檢索該型別的內容值。此程式碼引發了一些修剪警告,因為根據設計,修剪將刪除它在應用程式中靜態使用的程式集和型別。透過一點點重寫,特別是使用具有常量、完全限定型別名稱的 Type.GetType,該工具就能夠靜態地了解正在處理哪些型別。如果找到這些型別,該工具將保留這些型別的必要成員。因此,該工具不再發出警告,並且程式碼現在是相容的。
在撰寫本文時,StackExchange.Redis 庫中的最後一組警告尚未得到解決。該庫具有評估 LuaScript 的能力,這本身不是問題。問題在於指令碼參數的傳遞方式。以文件中的範例為例:
conststring 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
推薦閱讀:
點選下方卡片關註DotNet NB
一起交流學習
▲
點選上方卡片關註DotNet NB,一起交流學習
請在公眾號後台
回復 【路線圖】 獲取.NET 2023開發者路線圖
回復 【原創內容】 獲取公眾號原創內容
回復 【峰會視訊】 獲取.NET Conf開發者大會視訊
回復 【個人簡介】 獲取作者個人簡介
回復 【年終總結】 獲取作者年終總結
回復 【 加群 】 加入DotNet NB 交流學習群
長按辨識下方二維碼,或點選閱讀原文。 和我一起,交流學習,分享心得。