.NET Core剪裁器背后的技术及工作原理介绍

目录
  • 技术1、检测程序加载的程序集和类
  • 技术2、删除程序集中用不到的类
  • Dnlib使用的其他问题
  • 收获一、Dnlib保存含有本地代码的程序集时候遇到的问题
  • 收获二、Dnlib的其他应用

十天前,我发布了对.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 = (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")]
 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的应用场景?欢迎分享。

到此这篇关于揭秘.NET Core剪裁器背后的技术的文章就介绍到这了,更多相关.NET Core剪裁器内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • .NET Core剪裁器背后的技术及工作原理介绍

    目录 技术1.检测程序加载的程序集和类 技术2.删除程序集中用不到的类 Dnlib使用的其他问题 收获一.Dnlib保存含有本地代码的程序集时候遇到的问题 收获二.Dnlib的其他应用 十天前,我发布了对.NET Core程序进行瘦身的开源软件Zack.DotNetTrimmer,与.NET Core内置的剪裁器相比,Zack.DotNetTrimmer不仅对程序的剪裁效果更好,而且还支持WPF.WinForm程序. 很多朋友对于这个开源项目的原理很感兴趣,因此我将通过这篇文章为大家介绍它的工作

  • ASP.NET Core MVC中过滤器工作原理介绍

    过滤器的作用是在 Action 方法执行前或执行后做一些加工处理.使用过滤器可以避免Action方法的重复代码,例如,您可以使用异常过滤器合并异常处理的代码. 过滤器如何工作? 过滤器在 MVC Action 调用管道中运行,有时称为过滤器管道.MVC选择要执行的Action方法后,才会执行过滤器管道: 实现 过滤器同时支持同步和异步两种不同的接口定义.您可以根据执行的任务类型,选择同步或异步实现. 同步过滤器定义OnStageExecuting和OnStageExecuted方法,会在管道特定

  • .NET Core分布式链路追踪框架的基本实现原理

    分布式追踪 什么是分布式追踪 分布式系统 当我们使用 Google 或者 百度搜索时,查询服务会将关键字分发到多台查询服务器,每台服务器在自己的索引范围内进行搜索,搜索引擎可以在短时间内获得大量准确的搜索结果:同时,根据关键字,广告子系统会推送合适的相关广告,还会从竞价排名子系统获得网站权重.通常一个搜索可能需要成千上万台服务器参与,需要经过许多不同的系统提供服务. 多台计算机通过网络组成了一个庞大的系统,这个系统即是分布式系统. 在微服务或者云原生开发中,一般认为分布式系统是通过各种中间件/服

  • .NET Core分布式链路追踪框架的基本实现原理

    目录 分布式追踪 什么是分布式追踪 分布式系统 分布式追踪 分布式追踪有什么用呢 Dapper 分布式追踪系统的实现 跟踪树和 span Jaeger 和 OpenTracing OpenTracing Jaeger 结构 OpenTracing 数据模型 Span 格式 Trace Span OpenTracing API 分布式追踪 什么是分布式追踪 分布式系统 当我们使用 Google 或者 百度搜索时,查询服务会将关键字分发到多台查询服务器,每台服务器在自己的索引范围内进行搜索,搜索引擎

  • 从java源码分析线程池(池化技术)的实现原理

    目录 线程池的起源 线程池的定义和使用 方案一:Executors(仅做了解,推荐使用方案二) 方案二:ThreadPoolExecutor 线程池的实现原理 前言: 线程池是一个非常重要的知识点,也是池化技术的一个典型应用,相信很多人都有使用线程池的经历,但是对于线程池的实现原理大家都了解吗?本篇文章我们将深入线程池源码来一探究竟. 线程池的起源 背景: 随着计算机硬件的升级换代,使我们的软件具备多线程执行任务的能力.当我们在进行多线程编程时,就需要创建线程,如果说程序并发很高的话,我们会创建

  • jquery lazyload延迟加载技术的实现原理分析

    前言 懒加载技术(简称lazyload)并不是新技术,它是js程序员对网页性能优化的一种方案.lazyload的核心是按需加载.在大型网站中都有lazyload的身影,例如谷歌的图片搜索页,迅雷首页,淘宝网,QQ空间等.因此掌握lazyload技术是个不错的选择,可惜jquery插件lazy load官网(http://www.appelsiini.net/projects/lazyload)称不支持新版浏览器. lazyload在什么场合中应用比较合适? 涉及到图片,falsh资源,ifram

  • 批处理技术内幕 ECHO命令介绍

    众所周知,如果echo后面跟一个环境变量,但是该变量却为空时,相当于不加任何参数的echo,即输出当前echo是on还是off.很多文章或者教程给出的解决方案都是在echo后面加一个点号echo.,这样就会输出空行. 复制代码 代码如下: @echo off echo %demon.tw% :: ECHO is off. echo.%demon.tw% pause据我所知,用echo输出空行至少有十种方法: 复制代码 代码如下: @echo off echo= echo, echo; echo+

  • Entity Framework Core中执行SQL语句和存储过程的方法介绍

    无论ORM有多么强大,总会出现一些特殊的情况,它无法满足我们的要求.在这篇文章中,我们介绍几种执行SQL的方法. 表结构 在具体内容开始之前,我们先简单说明一下要使用的表结构. public class Category { public int CategoryID { get; set; } public string CategoryName { get; set; } } 在Category定义了两个字段:CategoryID.CategoryName. public class Sam

  • ASP.NET Core MVC中Required与BindRequired用法与区别介绍

    在开发ASP.NET Core MVC应用程序时,需要对控制器中的模型校验数据有效性,元数据注释(Data Annotations)是一个完美的解决方案. 元数据注释最典型例子是确保API的调用者提供了某个属性的值,在传统的ASP.NET MVC中使用的是RequiredAttribute特性类.该属性仍然可以在ASP.NET Core MVC中使用,但也提供了一个新的特性类BindRequiredAttribute. 今天让我们来看看它们之间的细微差别. RequiredAttribute的典

  • JavaScript中对循环语句的优化技巧深入探讨

    循环是所有编程语言中最为重要的机制之一,几乎任何拥有实际意义的计算机程序(排序.查询等)都里不开循环. 而循环也正是程序优化中非常让人头疼的一环,我们往往需要不断去优化程序的复杂度,却因循环而纠结在时间复杂度和空间复杂度之间的抉择. 在 javascript 中,有3种原生循环,for () {}, while () {}和do {} while (),其中最为常用的要数for () {}. 然而for正是 javascript 工程师们在优化程序时最容易忽略的一种循环. 我们先来回顾一下for

随机推荐