前言
本篇文章前面客觀評估了 .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 版本後釋出。
- 輕量化構建方案:
var simpleFunc = "return Math.Floor(arg1/0.3);"
.WithMetadata(typeof(Math))
.WithMetadata(typeof(Console)) //如果報 object 未定義, 加上
.ToFunc<double, double>();
- 智慧構建方案:
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>
後引用它你會發現錯誤,表面原因是 MyFile
和 System.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 的冰山一角,沒想到只是浮冰一塊。