.NET 建立動態方法方案及 Natasha V9

AzulX發表於2024-11-14

前言

本篇文章前面客觀評估了 .NET 建立動態方方案多個方面的優劣,後半部分是 Natasha V9 的新版特性。

.NET 中建立動態方法的方案

建立動態方法的不同選擇

以下陳列了幾種建立動態方法的方案:以下示例輸入為 value, 輸出為 Math.Floor(value/0.3):

emit 版本

 DynamicMethod dynamicMethod = new DynamicMethod("FloorDivMethod", typeof(double), new Type[] { typeof(double) }, typeof(Program).Module);
 ILGenerator ilGenerator = dynamicMethod.GetILGenerator();

 ilGenerator.Emit(OpCodes.Ldarg_0);  
 ilGenerator.Emit(OpCodes.Ldc_R8, 0.3);  
 ilGenerator.Emit(OpCodes.Div);  
 ilGenerator.Emit(OpCodes.Call, typeof(Math).GetMethod("Floor", new Type[] { typeof(double) }));  
 ilGenerator.Emit(OpCodes.Ret); 

 Func<double, double> floorDivMethod = (Func<double, double>)dynamicMethod.CreateDelegate(typeof(Func<double, double>));

表示式樹版本

 ParameterExpression valueParameter = Expression.Parameter(typeof(double), "value");

 Expression divisionExpression = Expression.Divide(valueParameter, Expression.Constant(0.3));
 Expression floorExpression = Expression.Call(typeof(Math), "Floor", null, divisionExpression);
 Expression<Func<double, double>> expression = Expression.Lambda<Func<double, double>>(floorExpression, valueParameter);

 Func<double, double> floorDivMethod = expression.Compile();

Natasha 版本

AssemblyCSharpBuilder builder = new();
var func = builder
    .UseRandomLoadContext()
    .UseSimpleMode()
    .ConfigLoadContext(ctx => ctx
        .AddReferenceAndUsingCode(typeof(Math))
        .AddReferenceAndUsingCode(typeof(double)))
    .Add("public static class A{ public static double Invoke(double value){ return Math.Floor(value/0.3);  }}")
    .GetAssembly()
    .GetDelegateFromShortName<Func<double, double>>("A", "Invoke");

Natasha 方法模板封裝版

該擴充套件庫 DotNetCore.Natasha.CSharp.Extension.MethodCreator 在原 Natasha 基礎上封裝,並在 Natasha v9.0 版本後釋出。

  1. 輕量化構建方案:
var simpleFunc = "return Math.Floor(arg1/0.3);"
    .WithMetadata(typeof(Math))
    .WithMetadata(typeof(Console)) //如果報 object 未定義, 加上
    .ToFunc<double, double>();
  1. 智慧構建方案:
var smartFunc = "return Math.Floor(arg1/0.3);".ToFunc<double, double>();

方案對比與分析

時由此可以看出,無論哪種動態構建,都無法掙脫 typeof(Math) 的束縛,甚至需要反射更多的後設資料。

後設資料在動態方法建立中是必不可少的結構,既然大家都依賴後設資料,不妨做一個對比;

方案名稱 編碼形式 Using 管理 記憶體佔用 解除安裝功能 構建速度 執行效能 斷點除錯 學習成本
Emit 繁瑣 不需要 .NET9 可解除安裝 .NET9 支援
Expression 一般 不需要 .NET9 可解除安裝 .NET9 支援
Natasha 一般 需要 可解除安裝 首次慢 支援
Natasha Method 簡單 需要 可解除安裝 首次慢 支援

場景

首先從場景角度來說,Emit / Expression 方案在 .NET 生態建設中扮演了非常重要的角色,也是不少高效能類庫的主要核心技術棧。而 Roslyn 的動態編譯技術從初期到完成,走的是一個全面的編譯流程,Natasha 是基於 Roslyn 的。雖然我是 Natasha 作者,但我還是建議,小打小鬧,使用表示式樹。而那些比較複雜的動態業務、動態框架、動態類庫使用 Natasha 更加舒適。舉幾個例子,比如規則引擎, Natasha 不是規則引擎,但你可以用 Natasha 來定製一個符合你操作習慣的規則引擎。再比如物件對映, Natasha 不是物件對映庫,但你可以用 Natasha 來定製一個符合你操作習慣的物件對映庫; 如果你覺得市面上的 ORM 用著都不順手,當然可以用 Natasha 來定製一款自己喜歡的 ORM.

編碼形式程與 Using 管理

從編碼過程來說,Emit 是比較繁瑣的,從編碼思維上來講,Emit 屬於“棧式程式設計”,資料的操作順序後進先出,與平常使用的 C# 關鍵字不同,它更趨近於底層的指令,你不會看到 if/switch/while 等操作,而是 Label 和一些跳轉指令,為此你也無法享受到正常編譯所帶來的便捷,甚至需要還原一些操作,比如 "str1" == "str2" 實際上要換成 Equal() 方法。
而表示式樹相比 Emit 就要舒服一點了,然而這也不是正兒八經的 C# 程式設計思維, 仍然還是需要經過加工處理的,如果你非常享受這種轉換過程,並能從中獲得成就感或者其他感覺,那無可厚非,它適合你。我想大多數人是因為沒有辦法才選擇動態方案,無論哪種,能從中獲得愉快感覺的開發者並不會很多。
相比前兩者,Natasha 需要注意的是 “域” 操作和 Using 引用,有了 “域” 更加方便隔離程式集和解除安裝,如果你的動態程式碼建立的程式集永遠都是有用的,使用預設域即可。反之那些需要解除安裝或更新的動態功能,就得選擇一個非預設域了。
除解除安裝之外,另一個參與動態構建過程的就是 Using ,因為 Using 程式碼是 C# 指令碼必不可少的一環,因此以 C# 指令碼方式構建動態功能需要考慮到 using System; 這類程式碼. 而 Using 中遇到的最大的問題是二義性引用 (CS0104) 問題:
假設有 namespace MyFile{ public static class File{} } 在 VS 裡開啟隱式 <ImplicitUsings>enable</ImplicitUsings> 後引用它你會發現錯誤,表面原因是 MyFileSystem.IO 名稱空間衝突了,實際原因是這兩個名稱空間都有 File 相關的後設資料,而語義分析不會對後續程式碼功能進行推斷你到底想使用哪個 File, 這種情況在複雜的程式設計環境下或許會出現,不可避免只能發現和改正。

一旦發生這種情況,您需要排除不想引用的 Using.

//排除 System.IO
builder.AppendExceptUsings("System.IO");

我們繼續看第四種,基於 Natasha 封裝的動態方法構建,非常的簡單,其中:

  • 輕量化構建寫法是按需引用後設資料編譯成委託。
  • 智慧構建寫法是直接在後設資料和 using 全覆蓋的情況下編譯成委託,該寫法的前提是預熱了後設資料和 Using 集合,詳情可以看 Natasha 預熱相關的方法。

記憶體佔用

前二者的編譯佔用系統記憶體很少,畢竟是直接一步到位的做法,少了很多分析轉換快取的過程。可以這麼理解,前二者是 Roslyn 編譯中的一環。

解除安裝功能

後文 Natasha 的"域"均用 AssemblyLoadContext(ALC) 代替。

限定在本文4種編碼案例範圍內,目前我還沒看到過關於 直接解除安裝 Emit/表示式樹 建立的動態方法相關的文章。
但 .NET9 推出的 PersistedAssemblyBuilder 將允許編譯 Emit 並輸出流到 ALC 中。

PersistedAssemblyBuilder ab = ....;
using var stream = new MemoryStream();
ab.Save(stream);

NatashaDomain domain = new("MyDomain");
var newAssembly = domain.LoadFromStream(stream);

雖然 .NET9 支援儲存程式集了,但 ALC 的解除安裝功能有點難搞,.NET 官方對 ALC 的解除安裝操作幾乎是無能為力的,從理論上來講只要你的類在使用中,這個類就無法被解除安裝,這種情況很多,一個靜態泛型類,或一個全域性事件,或被編譯到某個不被清理的方法中,又不提供清理方法,他們就會成為程式的殭屍型別。官方沒有辦法強制對正在使用的資料做些什麼強制處理。假如我的程式有 60 個依賴項,我需要找到這些依賴項的作者,也許作者不止 60 個,然後一一詢問他們:請問您的庫如何能夠清除儲存在您這裡的 ALC 建立的後設資料?然後附上一些除錯證據,告訴他,在 2 代 GC 中發現了大量無法被釋放的後設資料,這些後設資料與你的 XXX 有關。讀到這裡你覺得非常難辦,甚至有點荒謬,沒錯,就是這樣,也只能這樣。所以我在製作 HotExector 的過程中也不斷的思考和實驗整域代理以遮蔽域外引用的干擾。
話說回來如果是自己封裝的框架,那麼這個解除安裝的問題是會很好解決,因為你知道什麼東西該清理,什麼欄位該置空。

構建速度

與記憶體佔用說明類似,一個完整的編譯過程肯定要比其中一環佔用的時間長,況且 Roslyn 內部還會快取和預熱一些資料。首次編譯後,編譯速度就會非常的快。

執行效能

如果被編譯的 Emit 程式碼邏輯能夠和 Roslyn 內部最佳化後的指令碼邏輯一樣,那麼執行效能理論上是持平的。例如在多條 if 或 switch 的數值查詢邏輯中,Roslyn 可能會對這些查詢分支進行最佳化,比如使用二分查詢樹代替原有的查詢邏輯,如果你想不到這些最佳化點, Emit 程式碼的效能只能依靠後續 JIT 的持續最佳化來提高了,因為考慮到 JIT 後續的最佳化可能會讓它們都達到最優結果,因此都給了 “高”。而二者的區別開發者應該瞭解,相比 Emit 原生程式設計,Roslyn 編譯的 Emit 邏輯更加優秀和高效能。

斷點除錯

Natasha 自 V8 版本起開始支援指令碼斷點除錯,V9 版本升級了不同平臺、不同 PDB 輸出方式的相容性最佳化, Natasha 編譯框架支援 .netstd2.0。
而 Emit 也迎來的自己的除錯方案,上文提到 .NET9 的 PersistedAssemblyBuilder,透過使用該類的 GenerateMetadata 方法來生成後設資料流,進而建立 PortablePdbBuilder 除錯資料例項,然後轉化為 Blob (BlobBuilder),最後寫入 PDB 流. 有了 PDB 檔案, Debug 斷點除錯將變得可行。

Natasha 以及動態方法模板的優勢

套娃編譯

使用 Natasha 可以進行套娃編譯,使用動態編譯進行動態編譯,這種邏輯很複雜,舉個簡單的例子,假如需求是生成動態型別 A,在 A 的靜態初始化方法中生成一個動態委託 B, 而且 B 的部分邏輯是根據動態型別 C 和 D 所接受到的資料決定的。這在表示式樹和 Emit 的編碼思維中,可能對資料和型別做一箇中轉處理,而且在編譯過程中要用到還沒有被編譯的 A 的成員後設資料,這裡很繞。這種情況我更推薦使用 Natasha ,因為是考慮到學習成本和時間成本,按照正常思維 5 分鐘編寫完指令碼,為啥還要用 20 分鐘甚至 1 小時去找解決方案,設計快取,定製執行時強編碼規則呢?

私有成員

很多開發者應該都有讀原始碼的習慣,甚至在高效能場景會對原始碼進行魔改和定製,部分開發者有 200% 的信心保證他們獲取的私有例項和方法不會在程式中被亂用,這裡就會遇到一些問題,重新定製原始碼,或者使用未開放的例項方法會遇到訪問許可權的問題。節省時間和篇幅直接上例子:

已知在支援熱過載的 MVC 框架中,有 IControllerPropertyActivator / IModelMetadataProvider 兩個服務例項,它們提供了私有方法 ClearCache 方法清除後設資料快取的介面,但 IControllerPropertyActivator 介面由於訪問限制,寫程式碼時 IDE 會報錯,事已至此,執行時要拿到 IControllerPropertyActivator 介面型別只能先獲取它的例項然後透過反射獲取型別,而這裡的操作就是矛盾的操作,如果我不知道哪個型別實現了該介面,或者說實現介面的型別也是個 private 級別,那麼我又該如何獲取到例項.

Natasha V9 版以前需要自己定製開放私有操作,我們看一下 V9 更新後的操作:

//開啟私有編譯開關
builder.WithPrivateAccess();
//改寫指令碼
builder.Add(script.ToAccessPrivateTree("Microsoft.AspNetCore.Mvc.ModelBinding","Microsoft.AspNetCore.Mvc.ModelBinding.Metadata"));
//或
builder.Add(script.ToAccessPrivateTree(typeof(IModelMetadataProvider),"Microsoft.AspNetCore.Mvc.ModelBinding.Metadata"));
//或
builder.Add(script.ToAccessPrivateTree(instance1,instance2...));

開啟以上選項,Natasha 將重寫語法樹以支援私有操作。最終指令碼無需任何處理,一氣呵成,以下是使用 Natasha 繞過了訪問檢查的指令碼案例:

//該指令碼如果在 IDE 中會有訪問許可權問題
var modelMetadataProvider = app.Services.GetService<IModelMetadataProvider>();
var controllerActivatorProvider = app.Services.GetService<IControllerPropertyActivator>();
((DefaultModelMetadataProvider)modelMetadataProvider).ClearCache();
((DefaultControllerPropertyActivator)controllerActivatorProvider).ClearCache();

安全相關

有人說使用指令碼會導致安全問題,我覺得這種說法太過片面,不應該把人為因素強加到某個類庫中,Natasha 不會自行的為應用程式提供後門漏洞,任何上傳的文字和圖片都需要有嚴格的稽核,包括上傳的指令碼,即便沒有非法的網路請求程式碼,佔用資源,資料安全等問題的程式碼也要進行嚴格排查。對於需要大量動態指令碼的支援的服務,服務應該嚴格限制後設資料和規範功能指令碼粒度。

Natasha V9 新版變化

專案主頁:https://github.com/dotnetcore/Natasha

鏈式初始化 API

為了讓初始化更容易懂,在新版本中增加了一組鏈式操作的 API.
此組 API 更容易控制 Natasha 的初始化行為。

NatashaManagement
    .GetInitializer()
    .WithMemoryUsing() //不寫這句, Natasha 將不會掃描和預存記憶體中的 UsingCode. 
    .WithMemoryReference()
    .Preheating<NatashaDomainCreator>();

注:在使用智慧模式時,預熱 Using 和 Reference 是必要的,除非你能很好的管理這些。

更靈活的後設資料管理

  • 自 v9 版本起,簡單模式(自管理後設資料模式)支援單獨新增後設資料,單獨新增 using code, 而不是 引用和using 一起新增。
  • 編譯單元允許新增排除 using code 集合,builder.AppendExceptUsings("System.IO","MyNamespace",....), 該方法將防止指定的 using 被新增到語法樹中.

外部異常獲取

  • 增強錯誤提示,引發編譯異常時,將首先丟擲錯誤級別的異常,而不是警告。
  • 增加 “GetException” API, 在 Natasha 編譯週期外,獲取異常錯誤。

重複編譯

V9 版本在重複編譯方面做了一些增強,進一步增加複用性和靈活性。

1. 重複的檔案輸出

  • WithForceCleanOutput 該 API 使用後將開啟 強制清除檔案 開關,以避免在重複編譯時產生 IO 方面的錯誤。
  • WithoutForceCleanOutput 是預設使用的 API, 此時遇到重複檔案,Natasha 將自動改名到新檔案中, oldname 被替換成 repeate.guid.oldname.

2. 重複的編譯選項

  • WithPreCompilationOptions 該 API 開啟後,將複用上一次的生成的編譯選項(若沒有則生成新的),該編譯選項對應著 CSharpCompilationOptions 相關引數, 如果第二次編譯需要切換 debug/release,unsafe/nullable 等選項,請關閉該選項。
  • WithoutPreCompilationOptions 是預設使用的 API, 該 API 不會鎖定 CompilationOptions,保證每次編譯都是最新的。

3. 重複的引用

  • WithPreCompilationReferences 該 API 開啟後,將複用上一次的後設資料引用集。
  • WithoutPreCompilationReferences 是預設使用的 API。
  • 新版本中增強了對 “引用API” 的註釋,讓其行為更加容易被看懂。321,上鍊接

4. 私有指令碼支援

在使用前需要在工程中加上 IgnoresAccessChecksToAttribute.cs 檔案

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
    public class IgnoresAccessChecksToAttribute : Attribute
    {
        public IgnoresAccessChecksToAttribute(string assemblyName)
        {
            AssemblyName = assemblyName;
        }
        public string AssemblyName { get; }
    }
}
//給當前指令碼增加私有註解(標籤)
//privateObjects 可以是私有例項,可以是私有型別,也可以是名稱空間字串
classScript = classScript.ToAccessPrivateTree(privateObjects)

builder
.WithPrivateAccess() //編譯單元開啟私有後設資料支援
.Add(classScript );

5.編譯最佳化級別

注意:使用動態除錯前,請先在工具-選項-除錯中關閉[地址級除錯]。

Natasha v9 對編譯最佳化級別做了細化:

//普通 Debug 模式
WithDebugCompile(item=>item.ForCore()/ForStandard()/ForAssembly())
//加強 Debug 模式
WithDebugPlusCompile(item=>item.ForCore()/ForStandard()/ForAssembly())
//普通 Release 模式
WithReleaseCompile()
//加強 Release 模式
WithReleasePlusCompile()

理論上的加強模式可以理解為“刨根問底,全部顯現”模式,雖然普通的模式就已經足夠用,但這個模式可以更細粒度的輸出除錯資訊,包括一些隱式的轉換。
注:實驗中沒有看到更為細緻的除錯結果,有經驗的同志可以告知我哪些程式碼可以呈現出更細膩的結果。

6. 其他 API

  • 新版本對 API 的註釋進行了大量中文重寫,小夥伴們可以看到更接地氣,容易懂的註釋,由於編譯單元 (AssemblyCSharpBuilder) 多采用狀態方式儲存使用者配置,因此在 API 上還簡單增加了複用方面的說明。
  • 熟知的 UseDefaultDomain() 已過時,更符合 API 本意的 UseDefaultLoadContext() 名稱更為合適, Domain 系列已經不能成為編譯單元的前沿 API, 從 V9 版本起 LoadContext 系列將取而代之。
  • 增加 CompileWithoutAssembly API, 允許開發者在編譯後不向域中注入程式集,程式將不實裝編譯後的程式集。

結尾

之前以為自己入了 Roslyn 的冰山一角,沒想到只是浮冰一塊。

謝謝俞佬在文件上的支援。

碼字不易,感謝看完,多謝點贊。

相關文章