揭祕.NET Core剪裁器背後的技術

微軟技術棧發表於2022-03-21

不久前,我釋出了對.NET Core程式進行瘦身的開源軟體Zack.DotNetTrimmer,與.NET Core內建的剪裁器相比,Zack.DotNetTrimmer不僅對程式的剪裁效果更好,而且還支援WPF、WinForm程式。

很多朋友對於這個開源專案的原理很感興趣,因此我將通過這篇文章為大家介紹它的工作原理。
image.png

技術1-檢測程式載入的程式集和類

微軟提供了用於對.NETCore的執行時行為進行分析的庫Diagnostics,它可以獲取豐富的執行時資訊,比如類的例項建立、程式集載入、類載入、方法呼叫、GC執行、檔案讀寫操作、網路連線等。Visual Studio中對每個方法的呼叫時間進行評估的工具就是使用Diagnostics實現的。

要使用Diagnostics庫,我們首先需要安Microsoft.Diagnostics.NETCore.Client和Microsoft.Diagnostics.Tracing.TraceEvent這兩個程式集,然後使用DiagnosticsClient類來連線被分析的.NET Core程式的程式。程式碼如下所示:

using Microsoft.Diagnostics.NETCore.Client;
using Microsoft.Diagnostics.Tracing;
usingMicrosoft.Diagnostics.Tracing.Parsers;
using Microsoft.Diagnostics.Tracing.Parsers.Clr;
using System.Diagnostics;
using System.Diagnostics.Tracing;
 
string filepath =@"E:\temp\test6\ConsoleApp1.exe";//被分析的程式路徑
ProcessStartInfo psInfo = newProcessStartInfo(filepath);
psInfo.UseShellExecute = true;
using Process? p = Process.Start(psInfo);//啟動程式
var providers = newList<EventPipeProvider>()//要監聽的事件
       {
           new EventPipeProvider("Microsoft-Windows-DotNETRuntime",
                EventLevel.Informational,(long)ClrTraceEventParser.Keywords.All)
       };
var client = new DiagnosticsClient(p.Id);//設定DiagnosticsClient監聽的程式
using EventPipeSession session =client.StartEventPipeSession(providers, false);//啟動監聽
var source = newEventPipeEventSource(session.EventStream);
source.Clr.All += (TraceEvent obj) =>
{
   if (obj is ModuleLoadUnloadTraceData)//程式集載入事件
    {
       var data = (ModuleLoadUnloadTraceData)obj;
       string path = data.ModuleILPath;//獲取程式集的路徑
       Console.WriteLine($"Assembly Loaded:{path}");
    }
   else if (obj is TypeLoadStopTraceData)//類載入事件
    {
       var data = (TypeLoadStopTraceData)obj;
       string typeName = data.TypeName;//獲取類名
       Console.WriteLine($"Type Loaded:{typeName}");
    }
};
source.Process();

不同型別的訊息對應source.Clr.All事件中的不同型別的物件,這些類都繼承自TraceEvent,我這裡分析的是程式集載入事件ModuleLoadUnloadTraceData和類載入事件TypeLoadStopTraceData。

這樣我們就可以得知程式執行過程中載入的程式集和型別資訊,這樣就知道哪些程式集和型別沒有被載入,從而我們就知道要刪除哪些程式集和型別了。

技術2-刪除程式集中用不到的類

Zack.DotNetTrimmer中提供了可以刪除程式集中用不到的類的IL的功能,這個功能使用dnlib這個庫來完成的程式集檔案的編輯。Dnlib是一個對.NET程式集檔案進行讀、寫、編輯的開源專案。

在Dnlib中,我們使用ModuleDefMD.Load來載入一個現有的程式集,Load方法的返回值是ModuleDefMD型別。ModuleDefMD代表程式集資訊,比如其中的Types屬性就代表程式集中的所有的型別。我們可以對ModuleDefMD以及其中的物件進行修改後,把修改完成的程式集呼叫Write方法再儲存到磁碟中。

比如,下面的程式碼用來把一個程式集中的所有非public型別都給改成public型別,並且把方法上修飾的Attribute全部清除:

using dnlib.DotNet;
​
string filename =@"E:\temp\net6.0\AppToBeTested1.dll";
ModuleDefMD module =ModuleDefMD.Load(filename);
foreach(var typeDef in module.Types)
{
   if (typeDef.IsPublic == false)
    {
       typeDef.Attributes |= TypeAttributes.Public;//修改類的訪問級別
    }
   foreach(var methodDef in typeDef.Methods)
    {
       methodDef.CustomAttributes.Clear();//清除方法的Attribute  
    }
}
module.Write(@"E:\temp\net6.0\1.dll");//儲存修改

下面是待測試的程式集的原始碼:

internal class Class1
{
       [DisplayName("AAA")]
       publicvoid AA()
       {
              Console.WriteLine("hello");
       }
}

如下是修改後的程式集的反編譯結果:

public class Class1
{
       publicvoid AA()
       {
              Console.WriteLine("hello");
       }
}

可以看到我們對於程式集的修改起作用了。

掌握了使用Dnlib對程式集進行修改的方法,我們就可以實現刪除程式集中用不到的型別的功能了,我們只要把對應的型別從ModuleDefMD的Types屬性中刪除掉即可。不過在實際操作中,這樣做會遇到問題,因為我們要刪除的類可能被其他的地方引用,儘管那些地方只是引用我們要刪除的類,並沒有真的呼叫,但是為了保證修改後程式集的校驗合法性,ModuleDefMD的Write方法仍然會做合法性校驗,否則Write方法就會丟擲ModuleWriterException異常,比如:

ModuleWriterException: 'A method was removedthat is still referenced by this module.'

因此,我們編寫程式碼需要對程式集做仔細的檢查,確保刪除每一個引用要被刪除的類的地方。因為類定義本身佔用的檔案尺寸很少,主要的程式碼的空間佔用都在類的方法體中,因此我找了一個替代方案,那就是並不刪除類,只是把類的方法體清空。

Dnlib中,方法對應的型別是MethodDef型別,MethodDef的CilBody 型別的Body屬性代表方法的方法體。如果方法擁有方法體(也就是不是抽象方法等),那麼CilBody的Instructions就代表方法體程式碼的IL指令的集合。因此我立即想到了通過下面的程式碼來清空方法的方法體:

methodDef.Body.Instructions.Clear();

但是在執行的時候,使用上面的程式碼清理後的ModuleDefMD進行儲存的時候,可能會引起程式集結構非法的問題,比如有的方法定義了返回值,如果我們直接清空方法體,就會造成方法沒有返回值被返回的問題。因此我換了一種思路,也就是把所有的方法體都改成throw null;這個C#程式碼對應的IL程式碼,因為所有的方法體都是可以改成丟擲一個異常的形式來保證邏輯的正確性。因此我編寫如下的程式碼來進行方法體的清理:

method.Body.ExceptionHandlers.Clear();
method.Body.Instructions.Clear();
method.Body.Variables.Clear();
method.Body.Instructions.Add(newInstruction(OpCodes.Nop) { Offset = 0 });
method.Body.Instructions.Add(newInstruction(OpCodes.Ldnull) { Offset = 1 });
method.Body.Instructions.Add(newInstruction(OpCodes.Throw) { Offset = 2 });

最後三行新增的IL程式碼就是對應thrownull這行C#程式碼。

請檢視專案的github地址獲取全部原始碼,專案地址:

https://github.com/yangzhongk...

Dnlib使用的其他問題

在使用Dnlib過程中,我還有一些其他的收穫,在這裡記錄下來與大家分享。

▍收穫一:Dnlib儲存含有原生程式碼的程式集時候遇到的問題

在使用上面我提到的方法清理程式集的時候,對於我們編寫的自定義程式集以及第三方NuGet包的程式集的時候,大部分是沒問題的。但是在使用同樣的方法處理PresentationCore.dll、System.Private.CoreLib.dll等.NET Core基礎程式集的時候遇到了問題,那就是即使我對程式集只是Load之後,不做任何的改動後,直接Write,程式集也會發生明顯的變小。比如我用下面的程式碼處理一下PresentationFramework.dll:

using (var mod =ModuleDefMD.Load(@"E:\temp\PresentationFramework.dll"))
{
   mod.Write(@"E:\temp\PresentationFramework.New.dll");
}

原始的PresentationFramework.dll大小是15.9MB,而儲存後新的檔案大小隻有5.7MB。經過詢問Dnlib作者得知,這些程式集含有原生程式碼(比如使用C++/CLI編寫的程式碼或者ReadyToRun / NGEN / CrossGen等格式的程式集),使用Write方法儲存的時候會忽略這些原生程式碼,這就是儲存後的程式集尺寸明顯變小的原因。我們可以使用NativeWrite方法代替Write方法,因為這個方法會保留原生程式碼。

不過,根據AsmResolver(一個和DnLib類似的開源專案)的作者Washi1337所說,NativeWrite方法會盡量儲存原生程式碼的結構因此無法減小程式集的尺寸,甚至有可能反而增大程式集的尺寸。而且在實際使用的時候,我發現對於這些程式集進行修改之後,程式就會啟動失敗,檢視Windows事件日誌,我發現是程式啟動的時候CLR啟動失敗造成的。根據Washi1337所說,如果只是程式集中含有ReadyToRun的原生程式碼,那麼只要去掉程式集中的ILLibrary標誌,讓CLR跳過ReadyToRun原生程式碼,而直接執行IL程式碼就行了,畢竟對於ReadyToRun優化後的程式集仍然儲存了原始的IL程式碼。但是我如Washi1337所說的操作之後,程式依舊啟動失敗,不清楚是什麼原因,因為含有原生程式碼的程式集無法被很好的剪裁,因此我沒有再深入研究,歡迎對CLR精通的朋友分享經驗。

▍收穫二:Dnlib的其他應用

由於DnLib可以修改程式集,因此我們可以使用它做很多的事情,比如修改程式的預設行為(你懂的)。我們可以使用DnLib編寫一個自己的程式碼混淆器或者實現面向切面程式設計(AOP)的靜態織入。
你還想到了哪些DnLib的應用場景?歡迎分享。


微軟最有價值專家(MVP)

微軟最有價值專家是微軟公司授予第三方技術專業人士的一個全球獎項。29年來,世界各地的技術社群領導者,因其線上上和線下的技術社群中分享專業知識和經驗而獲得此獎項。
MVP是經過嚴格挑選的專家團隊,他們代表著技術最精湛且最具智慧的人,是對社群投入極大的熱情並樂於助人的專家。MVP致力於通過演講、論壇問答、建立網站、撰寫部落格、分享視訊、開源專案、組織會議等方式來幫助他人,並最大程度地幫助微軟技術社群使用者使用 Microsoft 技術。

更多詳情請登入官方網站

相關文章