轻量化动态编译库 Natasha v8.0 正式发布!
.NET8.0 與 動態編譯
Hello 各位小伙伴,我于 2024年1月10日 發布了 Natasha 一個全新的里程碑版本 v8.0,對于老用戶而言,此次發布版本號跨度較大,是因為我決定使用新的版本號計劃,主版本號將隨 Runtime 版本號的增加而增加。
淺談 .NET8.0
在 .NET8.0 Runtime 方向的深度解析文章出來之前,八卦了一些新聞,例如前階段的文檔收費風波,就那一段時間我覺得粥里的烏江榨菜都不香了;又目睹馬某某說微軟不開源,這損犢子玩意,以馬祭碼吧。
.NET8.0 更新的東西真的太多了,僅作為八卦聊聊,比如略低分配的異步狀態機,ConfigureAwait 的改進,Win 線程池與托管線程池的切換,減少了 GCHandle 濫用的 Socket,向量運算提升字符操作的性能等,這么串起來,整個網絡通信技術棧均有受益,照這么下去,.NET10 時 Asp.net Core 的相關性能測試跑第一也不是不可能的了。話說回來,其中官方比較看重的一項提升 “SearchValues”。官方引入 "SearchValues.Create()" API 根據要查找的字符來返回不同的策略實現,本質上是為了緩解 O(M*N) 的性能問題,定位算法策略多達 10 種,包括簡單的"四八取值定位",純 Ascii 字符定位,范圍定位,向量運算定位,位圖網格定位等(算法名都沒找到,字面意思幫助理解), 除了向量算法屬于普通應用盲區,其他算法都蠻好理解的,向量算法可以參考時總寫過的一些博客。
動態編譯 與 Natasha
在介紹新版之前,必須讓新來者了解動態編譯相關的知識,動態編譯在 .NET 生態中一直扮演著重要角色,例如 Dapper , Json.net , AutoMapper , EFCore , 動態編譯版 Razor ,
Orleans 等類庫中或多或少都存在動態編譯相關的代碼,在 Source Generators 出現之前 [運行時動態] 一直是建設 .NET 生態的重要技能,但繁重的 IL 以及 Expression 代碼無疑不給開發者帶來巨大的維護和升級成本,不僅如此,在執行性能上, Emit 方法的執行性性能只能趨近于原生編譯,并不能超過(這里糾正一下看到某篇文章提到 emit 要比原代代碼編譯執行快的觀點), 然而 SG 以及 AOT 兼容性方案的出現不僅解決了一些動態代碼性能上的問題,還讓 .NET 生態順利開展出另個分支,即 AOT 生態。
說到 AOT, 在啟動耗時過長,內存拮據,服務端對發布包大小有嚴格限制這三類場景中,AOT 如今已經成為開發界所熱衷的方案。.NET8.0 中更加全面的支持了 AOT,Asp.net Core 推出了 WebApplication.CreateSlimBuilder() 作為 Web 的 AOT 方案,在 .NET8.0 發布后,除了官方類庫,應該屬老葉的 FreeSql 在動靜兼容上做的是又快又穩了。在 .NET8.0 之前更早實現兼容方案的,不得不提九哥的 WebApiClient。即使有這些前車之鑒,Natasha 也無法參考。 Natasha 作為動態編譯類庫不得不站在 .NET8.0 的另一個重大技術特性之上,即 .NET8.0 默認開啟的動態 PGO 優化。AOT 并不能作為最佳的性能選擇方案,相反運行時的最簡動態策略以及對機器碼的動態優化更加合適性能敏感場景。在此之前我也曾仔細想過 [編譯時動態] 的適用場景有多么廣泛,能否取代 [運行時動態],結論是不管 [編譯時動態] 有多么優秀也無法取代 [運行時動態],徹底放棄 [運行時動態] 的做法也是十分欠考慮的,而且在純動態業務的場景中 SG 以及 AOT 方案是十分無力的,此時需要一個 [運行時動態] 方案來達到業務目標,這里推薦使用 Natasha.
那么使用新版 Natasha 來完成 [運行時動態] 的相關功能有什么好處呢?
答案是高效快速、輕量方便,智能省心.
Natasha 是基于 Roslyn 開發的,它允許將 C# 腳本代碼轉換成程序集,并享受編譯優化、運行時優化帶來的性能提升;在易用性上新版 Natasha 組件層次分明,API 規范可查,在保證靈活性的同時還封裝了很多細節;在擴展性上 Natasha 每次更新都會盡量挖掘 Roslyn 的隱藏功能給大家使用;在封裝粒度上,Natasha 自有一套減少用戶編譯成本的方案,讓更多的細節變得透明,接下來可以看一看新版 Natasha 都有哪些變化。
Natasha 項目地址:https://github.com/dotnetcore/Natasha
Natasha8.0 的新顏
開發相關
Natasha 應用用了前一篇文章提到的 CI/CD Runner,并加以實戰改造,在 PR 管理,ISSUE 管理等管道功能上得到了便利的支持。
此版本我們有三個大方向上的編碼任務,分別是功能性上的輕量化路線,擴展性上的動態方法使用率統計,以及兼容性上對 standard2.0 的支持。
Natasha 從本次更新起,停止了對非 LTS .NET 版本進行 UT 測試,在開發者使用非 LTS 版本 Runtime 時小概率可會出現意外情況,若遇到可提交 issue。
一. API 命名規范
-
With 系列 API: 帶有關閉、排除、枚舉附加值等條件狀態開關時使用的 API。 例如:
WithCombineUsingCode和WithoutCombineUsingCode,WithHighVersionDependency、WithLowVersionDependency、WithDebugCompile、WithReleaseCompile、WithFileOutput等,又例如編譯選項的 API 都是作為附加條件賦給選項的,因此都由 With 開頭(注:與 Roslyn 風格不同,With 方法不返回新對象). -
Set 系列 API: 屬單向賦值類 API, 例如:
SetDllFilePath、SetReferencesFilter等. -
Config 系列 API: 具有對主類中,某重要組件的額外配置,通常是各類 options 操作的 API, 例如:
ConfigCompilerOption、ConfigSyntaxOptions等. -
特殊功能 API: 此類 API 需要非常獨立且明確的功能,常用而顯眼,例如
UseRandomDomain、UseSmartMode、OutputAsFullAssembly、GetAssembly等顯眼包 API.
二. 性能提升
新版 Natasha 使用并發的方式將兩種預熱方法(引用/實現程序集預熱)的執行時間從 .NET8.0 實驗環境的 2-4s 降低到了 0.700 -1 s 左右;預熱的內存漲幅從 60-70M 降到 30-40M。
新版 Natasha 允許開發者靈活管理元數據覆蓋策略,比如
- 合并共享域及當前域的元數據.
- 僅使用當前域元數據.
- 僅使用指定的元數據等.
這使得 Natasha 可以支持自定義輕量化編譯,在實驗案例中輕量化編譯比預熱編譯節省了約 15M 左右的內存。
以下是引用程序集與實現程序集的預熱耗時統計截圖
引用程序集預熱
實現程序集預熱
以下是引用程序集與實現程序集的預熱內存統計截圖
引用程序集預熱
實現程序集預熱
三. Standard2.0 兼容方案
新版編譯單元的依賴項變為了 Standard2.0, 編譯單元項目移除了 System.Reflection.MetadataLoadContext 依賴包, Natasha 將直接從文件中提取元數據,避免一些繁瑣的加載操作,另外我們還移除了對 DotNetCore.Natasha.Domain 的依賴,盡管域對于 Natasha 來說十分重要,域作為 Runtime 的重要特性,它嚴重牽制著 Natasha 的兼容性,為此我對 Natasha 的框架進行了重新設計,將域以及一些運行時方法交由第三方去實現,而 Natasha 只保留和調用 Standard2.0 的接口,這兩個接口為 DotNetCore.Natasha.DynamicLoad.Base 包中的 INatashaDynamicLoadContextBase 和 INatashaDynamicLoadContextCreator,開發者可以根據兩個接口自行實現域功能,但這里 Core3.0 以上版本我推薦使用 DotNetCore.Natasha.CSharp.Compiler.Domain Natasha 官方實現的域功能,該包繼承自 DotNetCore.Natasha.Domain , 這是一個功能強大且穩定的 .NET 域實現包。
當然 Natasha 的使用方式也發生了一些變化:
//首先向 Natasha 加載上下文中注入域創建者實現類 NatashaDomainCreator
//NatashaDomainCreator 來自包 DotNetCore.Natasha.CSharp.Compiler.Domain,實現了 INatashaDynamicLoadContextCreator 接口
NatashaManagement.RegistDomainCreator<NatashaDomainCreator>();
//若需要預熱,也可以直接使用泛型預熱,泛型預熱將自動調用 NatashaManagement.RegistDomainCreator<NatashaDomainCreator>();
NatashaManagement.Preheating<NatashaDomainCreator>(false, false);
與此同時,新版 Natasha 解耦了編譯單元及模板,部分開發者在使用 Natasha 時習慣自己構建腳本代碼,而不需要 Natasha 本身模板的參與,為此我們解耦了模板與編譯單元的相關代碼,現在您可以引用 DotNetCore.Natasha.CSharp.Template.Core 來使用模板的相關功能,或者單獨引用 DotNetCore.Natasha.CSharp.Compiler 僅使用編譯單元的功能。
對于運行時目前區分了 "Core" 和 "Framewokr" 版本,"Core" 相關的代碼將繼續維護著,而與 "Framework" 相關的代碼任務已經停止,從去年年底我已無精力去做 Framework 的兼容工作,經濟來源對于 2024 年的我來說是個巨大難題,更多的思考與嘗試都將圍繞著如何維持生活來展開,但是 Natasha 會接受 PR,接受開源貢獻者的代碼。如果您不想使用上一版本的 Framework 實現,不介意您聯系我進行有償定制,這里也希望諸各位的公司項目早日脫離 Framework 苦海。
四. 域的改進
提到動態編譯不得不說的一個前提就是“域”,再次強調這里所說的域是 .NETCore3.0 + 版本的 ALC (程序集加載上下文),Natasha 對 ALC 進行了較全面的封裝,您可以單獨引用 Natasha.Domain 以便進行插件加載等操作,
本次更新我對域操作進行了修正與補充:
- Natasha 實現的 ALC 將避開依賴程序集的重復加載。
- 我發現之前的代碼中,在共享域加載為主的邏輯中,ALC 默認將程序集交由共享域處理,共享域處理不過接由當前域處理,新版本在確定共享域存在程序集的情況下,將直接返回共享域的程序集,無需另外處理。
- 在依賴程序集被排除的情況下,如果該程序集在共享域中存在,將返回共享域的程序集。
新增 Natasha.CSharp.Compiler.Domain 項目繼承 Natasha.Domain 項目并實現基礎編譯接口。
使用域加載插件
domain1.LoadPluginXXX(file)
在 Natasha 中使用加載插件,并加載插件元數據及 Using Code.
var loadContext = DomainManagement.Random();
//或
var loadContext = (new AssemblyCSharpBuilder().UseRandomDomain()).LoadContext;
var domain = (NatashaDomain)(loadContext.Domain);
//排除基類接口,否則你反射出來的方法只能在當前域編碼使用(更多詳情請學習微軟官方關于插件的相關知識)。
Func<AssemblyName, bool>? excludeInterfaceBase= item => item.Name!.Contains("IPluginBase");
//獲取插件程序集.
var assembly = domain.LoadPluginWithHighDependency(file, excludeInterfaceBase);
//添加元數據以及 using code.
loadContext.AddReferenceAndUsingCode(assembly, excludeInterfaceBase);
五. 元數據管理優化
元數據以及 using code 對于 Roslyn 編譯來說屬于重點依賴對象,新版 Natasha 增加了 NatashaLoadContext 來管理元數據,在 vs 開發過程中,由于動態腳本沒有智能提示和隱式 using 覆蓋,因此早期 Natasha 推出了透明模式,讓元數據管理變得透明不可見,預熱過程將緩存元數據和 using code,使用時自動覆蓋元數據引用以及 using code。對于 using code 的全覆蓋,類似于近期 vs 推出的隱式 usings 的功能,Natasha 還為編譯單元增加了語義過濾器的支持,以便自動處理編譯診斷。
同時 NatashaLoadContext 還支持解析實現程序集和引用程序集,早期 Natasha 僅在預熱時會緩存引用程序集的元數據,而如今,Natasha 不僅支持兩種程序集的預熱還支持在不預熱的情況下允許開發者自管理元數據。
/// <summary>
/// 預熱方法,調用此方法之前需要調用 RegistDomainCreator<TCreatorT> 確保域的創建
/// </summary>
/// <param name="excludeReferencesFunc"></param>
/// <param name="useRuntimeUsing">是否使用實現程序集的 using code</param>
/// <param name="useRuntimeReference">是否使用實現程序集的元數據</param>
/// <param name="useFileCache">是否使用 using 緩存</param>
public static void Preheating(
Func<AssemblyName?, string?, bool>? excludeReferencesFunc,
bool useRuntimeUsing = false,
bool useRuntimeReference = false,
bool useFileCache = false);
預熱案例1: 自動注入域實現,從內存中的 [實現程序集] 中提取元數據和 using code.
NatashaManagement.Preheating<NatashaDomainCreator>(true, true);
預熱案例2: 手動注入域實現, 從 refs 文件夾下的 [引用程序集] 中提取元數據和 using code. (需提前引入 DotNetCore.Compile.Environment 包).
NatashaManagement.RegistDomainCreator<NatashaDomainCreator>();
NatashaManagement.Preheating(false, false);
預熱案例3: 自動注入域實現,從 refs 文件夾下的 [引用程序集] 中提取 using code. (需提前引入 DotNetCore.Compile.Environment 包),從內存中的[實現程序集]中提取元數據, 此種方法一旦運行過一次,就會產生 using 緩存文件,此時即使刪除 refs 文件夾程序仍會正常工作.
NatashaManagement.Preheating<NatashaDomainCreator>(false, true, true);
六. 多種編譯模式
1. 智能編譯模式
使用智能編譯模式,編譯單元 AssemblyCSharpBuilder 將默認合并 共享加載上下文(NatashaLoadContext.DefaultContext) 和 當前上下文(builder.LoadContext) 的元數據以及 using code,并自動開啟語義過濾,如下是較完整的使用代碼:
1.若不使用內存程序集,則需要引入 DotNetCore.Compile.Environment 來輸出引用程序集。
2.預熱并注冊域實現。
3.啟用智能模式編碼。
NatashaManagement.Preheating<NatashaDomainCreator>();
AssemblyCSharpBuilder builder = new();
var myAssembly = builder
.UseRandomDomain()
.UseSmartMode() //啟用智能模式
.Add("public class A{ }")
.GetAssembly();
2. 輕便編譯模式
新版 Natasha 允許開發者使用編譯單元進行輕量級編譯,如果您只是想創建一個計算表達式或者一個簡單邏輯的映射,建議您使用編譯單元的輕便模式進行動態編譯。輕便模式不會合并主域的元數據和 using 代碼,只會使用當前域的,并且不會觸發語義過濾。
AssemblyCSharpBuilder builder = new();
builder
.UseRandomDomain()
.UseSimpleMode() //啟用輕便模式
.ConfigLoadContext(ldc=> ldc
.AddReferenceAndUsingCode(typeof(Math).Assembly)
.AddReferenceAndUsingCode(typeof(MathF))
.AddReferenceAndUsingCode(typeof(object)))
.Add("public static class A{ public static int Test(int a, int b){ return a+b; } }");
var func = builder
.GetAssembly()
.GetDelegateFromShortName<Func<int,int,int>>("A", "Test");
func(1,2);
3. 自定義編譯模式
AssemblyCSharpBuilder builder = new();
builder
.UseRandomDomain()
.WithSpecifiedReferences(元數據集合)
.WithoutCombineUsingCode()
.WithReleaseCompile()
.Add("using System.Math; using System; public static class A{ public static int Test(int a, int b){ return a+b; } }");
其中 WithSpecifiedReferences 方法允許您傳入引用集合,例如 Roslyn 成員提供的Basic.Reference.Assemblies引用程序集包。由于案例中指定了 WithoutCombineUsingCode 方法,該方法將不會自動覆蓋 using code, 因此腳本中需要手動添加 using code例如 using System;。
七. 動態調試
新版本 Natasha 允許在編譯單元在指定 Debug 編譯模式后,使用 VS 進入到方法內進行調試.
同時這里介紹一種隱藏的 Release 模式,該模式允許在生成程序集時攜帶有 Debug 相關的信息,之前被定義為 Debug 的 Plus 版本/可調試的 Release 模式,還可以增加您反編譯時的可讀性(這個功能 Roslyn 隨后幾個版本可能會加入到優化級別的枚舉中暴露給開發者)。
也許我們已經在 VS 中體驗過了?這個功能后續我會繼續跟進測試研究。
//調試信息寫入文件,原始的寫入方式,對 Win 平臺支持良好
builder.WithDebugCompile(item=>item.WriteToFileOriginal())
//調試信息寫入文件,兼容性寫入方式
builder.WithDebugCompile(item=>item.WriteToFile())
//調試信息整合到程序集中
builder.WithDebugCompile(item=>item.WriteToAssembly())
//Release 發布無法進行調試
builder.WithReleaseCompile()
//Release 模式將攜帶 debugInfo 一起輸出
builder.WithFullReleaseCompile()
案例
AssemblyCSharpBuilder builder = new();
builder
.UseRandomDomain()
.UseSimpleMode()
.WithDebugCompile(item => item.WriteToAssembly())
.ConfigLoadContext(ldc=> ldc
.AddReferenceAndUsingCode(typeof(object).Assembly)
.AddReferenceAndUsingCode(typeof(Math).Assembly)
.AddReferenceAndUsingCode(typeof(MathF).Assembly));
builder.Add(@"
namespace MyNamespace{
public class A{
public static int N1 = 10;
public static float N2 = 1.2F;
public static double N3 = 3.44;
private static short N4 = 0;
public static object Invoke(){
int[] a = [1,2,3];
return N1 + MathF.Log10((float)Math.Sqrt(MathF.Sqrt(N2) + Math.Tan(N3)));
}
}
}
");
var method = builder
.GetAssembly()
.GetDelegateFromShortName<Func<object>>("A", "Invoke");
//斷點調試此行代碼
var result = method();
八. 程序集輸出
Natasha 8.0 版本允許您在動態編譯完成后輸出完整程序集或引用程序集,注意這里并沒有進行什么智能判斷,需要您手動控制行為,域加載引用程序集會引發異常。請看以下例子來達到僅輸出的目的。
//編譯結果為引用程序集,且寫入文件,且不會加載到域。
builder
.OutputAsRefAssembly();
.WithFileOutput()
.WithoutInjectToDomain();
注: 如果您希望把 Natasha 作為一個插件生產器,那么很遺憾,目前它并不能像 VS 編輯器那樣輸出完整的依賴以及依賴文件。
九. 輸出文件
Natasha 支持 dll/pdb/xml 文件輸出,其中 xml 存儲了程序集注釋相關的信息。參考 API
//該方法將使程序集輸出到默認文件夾下的 dll/pdb/xml 文件中
//可傳入一個文件夾路徑
//可以傳入三個文件的路徑
builder.WithFileOutput(string dllFilePath, string? pdbFilePath = null, string? commentFilePath = null)
builder.WithFileOutput(string? folder = null);
//分離的 API
builder.SetDllFilePath/SetPdbFilePath/SetCommentFilePath();
周邊擴展
一. 動態程序集方法使用率統計
眾所周知,單元測試中測試方法覆蓋率統計通常使用 VS 自帶的工具進行靜態統計,還有 CLI 工具,這里 Natasha 推出一種新的擴展,允許開發者動態的統計[由 Natasha 生成的動態程序集]中的[方法]使用情況,目前已通過測試,并發布了第一個擴展包。此項技術還需要搜集需求和建議,因此我們的 ISSUE 被設置為 phase-done,歡迎大家留言提需求和建議。
使用方法:
- 引入
DotNetCore.Natasha.CSharp.Extension.Codecov擴展包。 - 編碼并獲取結果。
builder.WithCodecov();
Assembly asm = builder.GetAssembly();
List<(string MethodName, bool[] Usage)>? list = asm.GetCodecovCollection();
情景假設: A 類中有方法 Method , Method 方法體共 6 行代碼邏輯,在執行過程中僅執行了前4行。
result 集合如下:
"MyNamespace.A.Method":
[0] = true,
[1] = true,
[2] = true,
[3] = true,
[4] = false,
[5] = false,
二. 動態只讀字典
目前該庫還是維護狀態,因為它是僅 Natasha 關鍵項目之外的最重要項目,但目前沒有隨著 Natasha 發布新版。
動態只讀字典通過正向特征樹算法,計算最小查找次數(權值)來動態構建一段查找代碼,并交由 Natasha 編譯,并提供 GetValue 、 TryGetValue 、Change 、索引操作。
我對 .NET8.0 推出的凍結字典進行了性能對比,對比環境 .NET8.0, 字典類型 FrozenDictionary<string, string>, 對比結果:
凍結字典除非后續在 JIT 動態優化出更簡潔高效的代碼,否則它無法在這個場景中超越動態字典,主打性能的類庫越精細越不好優化,特征算法目前來講十分復雜且構建低效,在特征過多時構建延遲十分明顯,代碼上需要進行優化與重構,Swifter.Json 作者提出了差異算法,且經過案例推演也證實差異算法在某些場景中可以取得更小的權值,因此我們需要引入差異算法來與特征算法形成競爭,對于代碼腳本來說,下一步我將使用更高效的 Runtime API 來提高代碼執行性能,爭取在下一個版本呢取得更好的性能,后續我們還將橫向對比 Indexof / SearchValue 等高性能查找算法,以確定在特殊情況下是否能夠借鑒 Runtime 中的算法來提升性能。
在性能過剩的今天,ConcurrentDictionay 已經滿足大部分人的需求了,這個類庫沒有帶給我任何金錢收益和榮譽成就,甚至至今為止也未受到過任何需求,因此此庫優先級對我來說很低,對一個初級算法都不到的人來說,這庫挺令我頭疼,也許最好的走向是讓一個英語好的,頭腦思路清晰的小伙子把算法思路提交給官方,讓官方動態優化凍結字典。
結尾
即便 Roslyn 版的 Natasha 已經發布幾年時間,但我對 Roslyn 仍然有一種陌生且無力的感覺,Roslyn 文檔少的可憐,更多的功能還需要自己去研究挖掘,我會將一些提上日程的重要開發計劃發布到 issue 中并征集意見,例如:https://github.com/dotnetcore/Natasha/issues/240 , 開發不易,求個 Star。
Natasha 項目地址:https://github.com/dotnetcore/Natasha
Natasha 文檔地址: https://natasha.dotnetcore.xyz/zh-Hans/docs/ (文檔站點技術更新,稍晚俞佬將進行修復和上傳)
總結
以上是生活随笔為你收集整理的轻量化动态编译库 Natasha v8.0 正式发布!的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Skywalking(8.7)安装以及d
- 下一篇: 面试官:禁用Cookie后Session