十天前,我發布了對.NET Core程式進行瘦身的開源軟體Zack.DotNetTrimmer,與.NET Core內置的剪裁器相比,Zack.DotNetTrimmer不僅對程式的剪裁效果更好,而且還支持WPF、WinForm程式,
很多朋友對于這個開源專案的原理很感興趣,因此我將通過這篇文章為大家介紹它的作業原理,
技術1、檢測程式加載的程式集和類
微軟提供了用于對.NET Core的運行時行為進行分析的庫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;
using Microsoft.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 = new ProcessStartInfo(filepath);
psInfo.UseShellExecute = true;
using Process? p = Process.Start(psInfo);//啟動程式
var providers = new List<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 = new EventPipeEventSource(session.EventStream);
source.Clr.All += (TraceEvent obj) =>
{
if (obj is ModuleLoadUnloadTraceData)//程式集加載事件
{
var data = https://www.cnblogs.com/rupeng/archive/2022/03/21/(ModuleLoadUnloadTraceData)obj;
string path = data.ModuleILPath;//獲取程式集的路徑
Console.WriteLine($"Assembly Loaded:{path}");
}
else if (obj is TypeLoadStopTraceData)//類加載事件
{
var data = https://www.cnblogs.com/rupeng/archive/2022/03/21/(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")]
public void AA()
{
Console.WriteLine("hello");
}
}
如下是修改后的程式集的反編譯結果:
public class Class1
{
public void AA()
{
Console.WriteLine("hello");
}
}
可以看到我們對于程式集的修改起作用了,
掌握了使用Dnlib對程式集進行修改的方法,我們就可以實作洗掉程式集中用不到的型別的功能了,我們只要把對應的型別從ModuleDefMD的Types屬性中洗掉掉即可,不過在實際操作中,這樣做會遇到問題,因為我們要洗掉的類可能被其他的地方參考,盡管那些地方只是參考我們要洗掉的類,并沒有真的呼叫,但是為了保證修改后程式集的校驗合法性,ModuleDefMD的Write方法仍然會做合法性校驗,否則Write方法就會拋出ModuleWriterException例外,比如:
ModuleWriterException: 'A method was removed that 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(new Instruction(OpCodes.Nop) { Offset = 0 });
method.Body.Instructions.Add(new Instruction(OpCodes.Ldnull) { Offset = 1 });
method.Body.Instructions.Add(new Instruction(OpCodes.Throw) { Offset = 2 });
最后三行添加的IL代碼就是對應throw null這行C#代碼,
請查看專案的github地址獲取全部源代碼,專案地址:https://github.com/yangzhongke/Zack.DotNetTrimmer
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方法會盡量保存本地代碼的結構因此無法減小程式集的尺寸,甚至有可能反而增大程式集的尺寸(詳見https://github.com/Washi1337/AsmResolver/issues/267),而且在實際使用的時候,我發現對于這些程式集進行修改之后,程式就會啟動失敗,查看Windows事件日志,我發現是程式啟動的時候CLR啟動失敗造成的,根據Washi1337所說,如果只是程式集中含有ReadyToRun的本地代碼,那么只要去掉程式集中的ILLibrary標志,讓CLR跳過ReadyToRun本地代碼,而直接執行IL代碼就行了,畢竟對于ReadyToRun優化后的程式集仍然保存了原始的IL代碼,但是我如Washi1337所說的操作之后,程式依舊啟動失敗,不清楚是什么原因,因為含有本地代碼的程式集無法被很好的剪裁,因此我沒有再深入研究,歡迎對CLR精通的朋友分享經驗,
識訓二、Dnlib的其他應用
由于DnLib可以修改程式集,因此我們可以使用它做很多的事情,比如修改程式的默認行為(你懂的),我們可以使用DnLib撰寫一個自己的代碼混淆器或者實作面向切面編程(AOP)的靜態織入,
你還想到了哪些DnLib的應用場景?歡迎分享,
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/445843.html
標籤:.NET技术
上一篇:3. 堪比JMeter的.Net壓測工具 - Crank 進階篇 - 認識bombardier
下一篇:如何定義段函式
