代码分析工具 - IL 代码分析工具集
此工具分析一组程序集的 IL,查找未被另一组程序集使用的类型、方法和字段。这可以让你查看是否有未使用的遗留代码需要清理。
- 下载二进制文件 - 19.8 Kb
(此项目使用 Microsoft.Cci.dll 程序集,但不能分发。所以,你需要自己从 FxCop 安装中获取它。只需将此 DLL 移动到与 CodeAnalysisTools.exe 相同的文件夹中即可。)
- 下载项目文件 - 41.3 Kb
引言
本文最初是对一个工具的描述。但我决定将其变成代码分析工具的集合。我已经编写了几个工具,我将它们插入进来,并且还从其他人那里得到了关于可能的代码分析工具的一些很棒的反馈。因此,我将一点一点地用新的工具扩展这个工具集合,因为它们已经被编写和测试了。
我不知道为什么,但代码工具、代码分析和数据挖掘一直让我着迷。我一开始编写工具,比如 Visual Studio 插件,来自动化繁琐的编码任务。有一天,我雄心勃勃地开始尝试编写一个实现一组简单代码重构的工具(参见 Martin Fowler 的书,《重构》)。
但经过一番研究,我注意到已经有几款商业产品可以做到这一点,而当 Visual Studio 2005 发布时,它包含了七个相当简单的功能。所以,再写一个做同样事情的工具似乎是多余的。但这让我对代码重构有了更多的思考。
基本上,代码重构由两大任务组成。寻找需要重构的地方,然后实际重构代码。第二个相对容易。有几本(非常好的)书可以一步一步地告诉你如何完成这项任务。
但正是这第一步让我着迷。Fowler 似乎创造了“代码异味”这个词。基本上,代码异味是代码中的一个工件,它(可能)表明某些地方可能出了问题。在我看来,找到这些异味是困难的部分,特别是当你的代码库包含数千个类时。这些异味在噪音和混乱中很容易迷失,尤其是在经过多年的持续开发之后。它们只是被隐藏起来了。但这并不意味着它们不应该被修复。
所以在过去的几年里,我一直沉浸在分析工具的思想中,这些工具可以嗅探出这些异味,这就是这组工具的基础。本文将对这些工具中的每一个进行描述,包括它们的架构以及如何使用它们。
以下是此工具集合中包含的工具列表(以及我正在开发或考虑中的工具):
- 未使用分析:搜索您的代码,查找不再使用的类、方法和字段
- 可见性分析:搜索任何比所需暴露程度更高的类或方法
- 重复代码:搜索您的代码以查找重复和接近重复的函数
- 循环依赖:搜索任何具有循环引用依赖的类层次结构(即将推出)
Microsoft.Cci.dll
此工具依赖于 FxCop 附带的 DLL。您必须安装 FxCop(或只安装 Microsoft.Cci.dll),并且您很可能需要根据安装位置重置此项目中 Microsoft.Cci.dll 的程序集引用。
所以 .NET 的妙处在于(正如每个人现在都知道或应该知道的),无论您使用哪种语言,它都会被编译成 IL。这没什么新鲜的,对吧?没错。那么,如果我要编写一个代码分析工具,编写一个解析 C# 的工具是否有意义呢?不,因为那样我就必须编写一个解析 VB.NET 的工具……或者新的 Python.Net。整个过程会很愚蠢,因为那样我就必须为我想要支持的每种语言维护一个解析器。太没意思了!
但是如果我访问并使用编译后的 IL 进行代码分析,那么我的工具就可以在任何 .NET 应用程序上工作,无论它用什么语言编写。好的,那么我如何解析已编译程序集的实际 IL 呢?实际上有几种不同的方法。
你可以手动完成。Sorin Serban 写了一篇文章(解析方法体的 IL),正是这样做的。但这会变得相当复杂。你可以使用 Reflector 附带的 API 来获取 IL。微软研究院有一个名为 Phoenix 的项目,它也会为你提供一个用于挖掘 IL 的 API。还有一个 Mono 项目的 Cecil,它会加载一个程序集并提供一个表示程序集内容的对象图。我用过很多次,但它有时与 Visual Studio 编译的 DLL 不兼容,并且总是处于不断变化的状态(好事和坏事)。
但我最喜欢的 API 称为 Common Compiler Infrastructure,它随 FxCop (Microsoft.Cci.dll) 提供。它提供了一个程序集内容的对象层次结构/图,非常类似于 Cecil,但我从未遇到过它在加载任何程序集时抛出错误的情况。
要开始使用 CCI,请创建一个项目,并将 Microsoft.Cci.dll 添加到您的引用中(它位于您安装 FxCop 的位置),并为 Microsoft.Cci
添加一个 using
语句。您可能使用的根类是 AssemblyNode
类及其静态 GetAssembly
方法。此方法接受程序集的路径,并返回一个 AssemblyNode
实例。AssemblyNode
暴露的众多属性之一是 Types
。这是一个 TypeNodeList
,其中包含程序集中的每个 Type
。
AssemblyNode assembly = AssemblyNode.GetAssembly(someAssemblyPath);
foreach (TypeNode type in assembly.Types)
CheckType(type);
现在,一个 TypeNode
有一个 Members
属性,它是一个 MemberList
。此列表包含该类型的所有成员;字段、事件、方法、属性、子类型和命名空间。一个 Method
类是 Member
的子类,所以当您遍历该类型的 Members
时,请查找类型为 Method
的成员。
foreach (Member member in type.Members)
{
Method method = member as Method;
if (method != null)
{
this.ParseMethod(method);
continue;
}
}
既然你已经有了一个方法,你可以查看它的参数、属性以及它的 IL 指令列表。这正是它变得很酷的地方!明白了吗?你可以通过编程方式遍历程序集的 IL 代码并寻找特定的场景。这与 FxCop 所做的几乎完全相同。如果你曾编写过自定义 FxCop 规则,那么你很可能已经使用过 Method
对象的 IL 指令列表。
InstructionList instList = method.Instructions;
for (int index = 0; index < instList.Length; index++)
{
this.ParseInstructionForType(instList[index]);
this.ParseInstructionForMember(instList, index);
}
InstructionList
中的每个 Instruction
都有一个 OpCode
属性和一个 Value
属性(以及许多其他属性)。我不会进入 IL 教程模式,但 OpCode
属性就是指令。它告诉计算机要执行什么操作。Value
属性是 OpCode
将要操作的对象。例如,如果程序要将字段加载到堆栈上,则 OpCode
将是 ldfld
,而 Value
将是一个 Field
实例。使用所有这些,很容易弄清楚您的 IL 代码正在使用哪些类型、方法和字段。
“未使用”分析
在我的职业生涯中,我多次参与大型软件项目,其中包含数十个解决方案中的项目、数千个类和数万个方法。理顺所有这些代码始终是一项繁琐的工作,最终在几年更新和代码更改后,一些类和方法被弃用……未使用。但它们仍然留在代码中,因为没有人知道它们。
这通常被认为是不好的,因为它会使你的代码混乱,暴露超出你预期范围的内容,编译时间更长,并且在加载 DLL 时占用更多内存。
最近,我不得不在一个我不熟悉的代码领域工作。当我浏览代码时,我会查看每个类是如何以及在哪里使用的。当我发现几个不再使用但仍然存在的类时,我开始感到沮丧。这是我最讨厌的事情之一。
于是我开始寻找更多未使用的代码。我找到了不再使用的字段、方法和类。太棒了!没有什么比删除未使用的代码更有趣了!但是,寻找未使用代码的恼人之处在于,唯一能检查它是否真正在解决方案中任何地方都未使用的途径是编译(除非您使用反射,此时您只能进行字符串搜索),如果您的项目有 32 个,这可能需要一段时间。
为了加快这个过程,我着手编写一个工具,它遍历我所有的代码,查找每个类、方法和字段被引用或使用的地方。
“未使用”架构
此工具的架构相当简单。基本上有两个主要类;一个目录类和一个分析器类。我创建了一个 AssemblyCataloger
类,用于遍历程序集列表并为程序集中定义的每个类型、字段和方法(包括构造函数和属性)建立目录。然后我有一个 UsageAnalyzer
类,它接受 AssemblyCataloger
实例和要与目录进行检查的程序集列表。
分析器所做的是遍历列表中的每个程序集,并查找以任何方式使用或调用的任何类型、字段或方法。当我在目录中找到存在的类型、方法或字段时,我将目录中的该类型、方法或字段标记为已调用。在分析结束时,我然后遍历目录并查找目录中没有任何调用者的类型、方法和字段。这些被认为是未使用的。
我最初使用 Microsoft.Cci.dll 附带的访问者模型来“访问”每个类型、字段和方法以进行编目。您这样做的方法是创建一个继承自 Microsoft.Cci.StandardVisitor
的类。然后,您会覆盖各种 VisitXXX
方法,例如 VisitField
、VisitTypeNode
、VisitMethod
等。当您告诉访问者类对程序集运行时,它会遍历程序集并在找到与被覆盖的访问者对应的成员时调用每个被覆盖的访问者。例如,当它找到一个字段时,它会调用 VisitField
,您的被覆盖方法将被调用。因此,当被覆盖的 VisitXXX
方法被调用时,我将编目该项。
这很棒,非常简单,并且减少了我认为需要编写的代码量,以便遍历整个程序集以获取每个字段、方法和类型。唯一的问题是编目整个程序集需要很长时间,更不用说 30 多个程序集了。
然后我编写了另一个手动遍历程序集的目录器,发现我可以在 StandardVisitor
类所用时间的 30% 内完成一个程序集的目录。所以很明显,这就是我最终使用的模型。
“未使用”规则
分析器的结构与目录器非常相似,因为它以相同的方式遍历程序集。基本上,它加载一个您想要对照目录检查的程序集。它查看每个类型、方法、字段、事件、属性和参数,以查看它们是否使用或引用目录中的任何内容。这意味着要深入到 IL 层面。太棒了!
以下三个部分列出了我用来确定哪些类型、字段和方法被程序集使用的规则。实际上,它比我想象的要复杂得多。规则分为两类:“不编目”规则:这些规则告诉您甚至不要编目特定的类型、方法或字段。另一类规则称为“标记为已使用”规则:这些规则告诉您目录中的类型、字段或方法已被使用。
不编目类型规则
- 不要编目任何以“<”开头的类型:如果您在 Reflector 中打开任何程序集,您会找到的第一个命名空间是;它将始终包含一个名为
的类型,有时还会包含另一个名为
的类型。这些是自动生成的内部使用的类型。因此,我过滤掉(不编目)任何以字符“<”开头的类型。这很有效,因为 CLR 无论如何也不允许您手动定义带此字符的类型。
标记为已使用的类型规则
- 在目录中查找类型继承的接口并删除。
- 在目录中查找类型的基类型并删除。
- 在目录中查找任何修饰类型的属性并删除。
- 在目录中查找字段的类型并删除。
- 在目录中查找任何修饰字段的属性并删除。
- 在目录中查找事件的处理程序类型(委托)并删除。
- 在目录中查找任何修饰事件的属性并删除。
- 在目录中查找属性的返回类型并删除。
- 在目录中查找任何修饰属性的属性并删除。
- 在目录中查找任何修饰方法的属性并删除。
- 在目录中查找方法参数的类型并删除。
- 在目录中查找任何修饰方法参数的属性并删除。
- 在目录中查找方法的返回类型并删除。
- 在目录中查找任何修饰方法返回类型的属性并删除。
- 任何方法的第一个操作码是该方法所需的局部变量列表。在目录中查找每个局部变量的类型并删除它。
- 如果操作码是以下之一(
OpCode.Castclass
、OpCode._Catch
、OpCode.Newarr
、OpCode.Box
、OpCode.Initobj
、OpCode.Isinst
、OpCode.Unbox
、OpCode.Unbox_Any
、OpCode.Ldtoken
),则在类型目录中查找操作码的值并删除。 - 如果操作码是以下之一(
OpCode.Ldfld
、OpCode.Ldflda
、OpCode.Ldsfld
、OpCode.Ldsflda
、OpCode.Stfld
、OpCode.Ldftn
、OpCode.Stsfld
、OpCode.Ldtoken
)且操作码值为Field
,则在目录中查找字段的类型并删除。 - 如果操作码是以下之一(
OpCode.Call
、OpCode.Callvirt
、OpCode.Calli
、OpCode.Newobj
、OpCode.Ldvirtftn
、OpCode.Ldftn
、OpCode.Tail_
)且操作码值为Method
,则在目录中查找方法的声明类型并删除。
不编目方法规则
- 不编目静态构造函数:如果一个类型有一个静态字段,那么编译器将生成一个静态构造函数来初始化该字段。此外,静态构造函数无论如何都是显式调用的,它们在类型成员首次被调用时被调用。
- 不编目默认类型构造函数(无参数构造函数):每个类型都必须有一个这样的构造函数,所以不要编目它们。
- 不编目与类型基类型构造函数具有相同参数列表的实例构造函数:如果类型
B
继承自类型A
,并且A
有一个接受一个字符串的构造函数,而B
想要公开一个允许调用者将字符串传递给基类的构造函数,它也需要公开此构造函数重载。虽然如果从不实际调用它并不明确需要,但如果需要提供它被认为是“良好形式”。 - 不编目
Finalize
方法:类型终结器不是显式调用的,它们由 GC 调用,因此不编目它们。
标记为已使用方法规则
- 如果一个方法不在目录中,但它所属的类型实现了一个定义此方法的接口,则将该方法保留在目录中,因为它被该接口所需要。
- 如果操作码是以下之一(
OpCode.Call
、OpCode.Callvirt
、OpCode.Calli
、OpCode.Newobj
、OpCode.Ldvirtftn
、OpCode.Ldftn
、OpCode.Tail_
)且操作码值为Method
,则在方法目录中查找该方法并删除。 - 如果在上一条规则中找到并从目录中删除的方法是抽象或虚拟的,则向上和向下遍历方法声明类型继承层次结构,并查看其中一个类型是否声明了具有相同签名的方法。如果找到,也将其从方法目录中删除。
不编目字段规则
- 不编目常量字段:这是因为常量字段的值在编译时被硬编码到 IL 中。无法知道它是否被使用。
- 不要编目名为“
value__
”的字段:我不知道编译器为什么会把这些字段放进去,但是每个定义的枚举都含有一个编译器定义的名为“value__
”的私有字段。
标记为已使用的字段规则
- 如果操作码是以下之一(
OpCode.Ldfld
、OpCode.Ldflda
、OpCode.Ldsfld
、OpCode.Ldsflda
、OpCode.Stfld
、OpCode.Ldftn
、OpCode.Stsfld
、OpCode.Ldtoken
)且操作码值为Field
,则在字段目录中查找该字段并删除。
已知误报
通过反射调用的方法、类型和字段:由于反射调用通常以方法或字段的字符串名称启动,因此此类分析不会捕获任何通过反射调用的类型、方法或字段。
还可能存在引用链导致误报,目前我尚未对其进行分析。例如,考虑以下引用链:类 A
仅被类 B
使用,类 B
仅被类 C
的方法 x()
使用,而方法 x
在任何地方都未被调用。方法 C.x
将被标记为未使用,但类 A
和 B
不会被标记为未使用。现在,一旦您删除了方法 C.x
,类 B
将被标记为未使用。然后一旦 B
被删除,类 A
将被标记为未使用。但这需要手动执行很多步骤。
最终,我想修复这两个缺陷。
可见性分析
我最近收到的一个分析建议是检查类型、方法或字段的作用域是否比实际需要的更开放或更可见。例如,如果一个方法被标记为 public
,但没有其他程序集调用它,那么它可以被标记为 internal
。同样地,如果该方法仅被同一类中定义的其他成员调用,那么它可以被标记为 private
。
这有几个好处,例如减少“API 表面积”,减少文档和测试要求,以及减少可能的安全漏洞。更不用说,将成员的作用域限制在所需的最低可见性是一个好习惯。
嗯,考虑到我在“未使用”分析期间编目和分析程序集时收集到的所有数据,这种分析方式非常容易实现,所以我将其添加进去了。
注意:到目前为止,我只查找了 public
、private
和 internal
作用域。我尚未分析 protected
和 protected internal
(尚)。
未使用和可见性分析用法和结果
要开始任何分析(或两者),请单击“选择要检查的文件...”链接。这将弹出一个文件选择对话框。选择您要编目的程序集。
然后,点击“选择要检查的文件...”链接,再次选择一个或多个程序集。这些程序集将用于查看编目项是否被使用过。或者,您可以点击“>>”按钮,这将把“待编目”列表中的所有程序集带到“待分析”列表中。
接下来,选择分析选项。您可以分析类、方法、字段和可见性的任意组合(要分析可见性,您必须从类、方法和字段中至少选中一个)。
然后,单击“运行分析”按钮以实际运行分析。根据您编目和分析的程序集数量,这可能需要长达一分钟。我最大的分析使用了 42 个 DLL 和 EXE,大约用了 50 秒(尽管大部分时间都花在了填充 ListViews 以显示结果上)。
在用户界面的下半部分,有四个选项卡显示分析结果。每个选项卡显示有多少成员被编目以及有多少被发现未使用。下面的 ListView 是每个未使用成员的逐个成员明细。如果您双击任何行,您将获得该成员的详细信息。这很有用,这样您就可以找到有问题的成员并终止它的存在!
我针对 FxCop 运行了我的分析工具,编目了 FxCop 的两个内部 DLL,并针对它的所有七个程序集进行了检查;我得到了以下结果:
- 在编目的 110 个类中,有 2 个未使用。
- 在编目的 916 个方法中,有 173 个未使用。
- 在编目的 244 个字段中,有 10 个未使用。
- 在分析的 1270 个总成员中,有 71 个显示可见性过高。
我还对 Reflector 运行了该工具,它只有一个程序集。我得到的结果如下:
- 在编目的 363 个类中,有 15 个未使用。
- 在编目的 2500 个方法中,有 1313 个未使用。
- 在编目的 454 个字段中,有 0 个未使用(非常好!)。
- 在分析的 3317 个总成员中,有 983 个显示可见性过高(不太好;但 Reflector 确实公开了一个插件 API,所以这可以解释为什么有这么多未使用的公共成员)。
这一切意味着什么?
那么如果没人调用你的方法,这意味着你应该删除它吗?这取决于情况。如果你正在为产品定义一个公共 API,并且它对你的客户来说是一个有用的方法,那么不,不要删除。如果它是一个私有方法或字段,那么是的,它很可能来自于遗留代码并且不再使用,所以清除它。基本上,这个工具只是嗅探出一种特定类型的“异味”,但如何正确重构它取决于你。
重复代码分析
一种非常难闻的代码异味是重复代码。但是,正如我之前所说,如果您的项目中有数千个类,就很难找到它。当您需要一个类中已经存在的功能在另一个类中时,复制粘贴代码实在太容易了。我们都知道这不好,而且我们都或多或少地做过。
但这又增加了混乱和内存开销,更不用说可能滋生错误。如果一个函数,多年来被复制到五个不同的类中,然后确定该函数有一个错误。那么,希望您能记住所有您复制代码的其他地方,这样您也可以在那里修复它。但是,Joey 或 Stu 复制到您不知道的所有地方怎么办?
这就是这个工具背后的动机。它基本上扫描您的程序集,查找与其他方法完全重复或非常接近重复的方法。然后它(如上所示)报告所有找到的不同重复函数簇。然后由您来清理代码。
重复代码分析
为了确定一个函数是否是另一个函数的重复,我遍历每个函数,并构建一个字符串,该字符串由函数中每个 OpCode / OpCode 操作数对连接而成。OpCode 操作数是 OpCode 操作的对象。例如,call
OpCode 可以在方法 System.Console.WriteLine(string)
上操作,或者 ldfld
操作码可以在类字段上操作。
一旦我为函数构建了此字符串,我就会通过 MD5 哈希对其进行处理,以获取函数的 16 字节哈希值。我检查此哈希值是否已存在于哈希字典中。如果存在,我找到了一个重复项并将其报告给 UI。如果不存在,我将其添加到字典中,以便与其他函数进行检查。我哈希 IL/IL 操作数的原因是字符串会变得相当大,如果我使用字符串作为字典中的键,如果我的应用程序足够大(就像我分析整个 .NET 框架时那样),我可能会很容易耗尽内存。
在构建 OpCode 字符串时,有几个 OpCode 我不关心:它们是 nop
OpCode 和 _Locals
OpCode。nop
OpCode 在调试版本中用于将断点与 Visual Studio 中的代码同步。_Locals
OpCode 是每个函数的第一个 OpCode,它包含有关函数声明的所有变量的信息。
大量重复项!更多过滤
当我第一次对我的代码运行这个工具时,我发现了一个令人不安的重复方法数量……数百个重复方法。我的意思是,我知道我偶尔会复制一些方法,但不是那么多!仔细查看结果后,我发现了两个罪魁祸首:基本的 getter/setter 属性和默认构造函数。基本的 getter/setter 属性是那些除了返回字段或设置字段之外什么都不做的属性。嗯……这可以吗?是的。我不会担心这些。默认构造函数是那些除了调用其基类的构造函数之外什么都不做的构造函数,并且通常由 C# 编译器插入到您的程序集中。同样,这些都是可以忽略的噪音。
近似重复代码分析
这做得很好,但我自己想……“自己,那些执行相同处理步骤,但针对不同类型和方法的方法呢?” 这些可能是指示可以将方法重构到基类或辅助方法的指标。
例如,如果您有一个函数对 FileStream
执行一系列步骤,而另一个函数对 MemoryStream
执行完全相同的步骤。查找重复项算法不会找到它,因为它在计算哈希码时使用了 OpCode 操作数;即 FileStream
和 MemoryStream
的方法。但是,如果您只将 OpCode 包含在哈希中,而不包含操作数呢?您实际上可能会找到更多重构候选项!
事实证明我是对的。现有的算法使得添加此选项变得简单。以下是两个屏幕截图。第一个是针对 FxCop 二进制文件的重复代码检查,第二个是针对 FxCop 二进制文件的近似重复代码检查。请注意,它在近似重复分析中找到了更多重构候选项。
我一直在研究另一种重复代码分析(但尚未准备好公开),它只查看较小的 IL OpCode 组,而不是整个方法。这可以让你找到隐藏在函数中的重复代码块。例如,假设你有一个方法执行大量操作。然后它打开一个文件流,并将数据读入字节数组或字符串。然后方法继续执行更多操作。一个月后你写了一个新方法。这个方法做了一些需要打开和读取文件的事情,然后执行更多操作。
这两种方法可能做的事情截然不同,但它们包含一个重复的代码块;即打开和读取文件的代码。这听起来像是另一个重构的候选!这种类型的分析还可以查找同一函数中重复的代码块。你见过那些由老式 ASP 开发人员编写的 500 行函数吗?其中代码块一个接一个地被复制和粘贴,一遍又一遍?这种类型的分析也应该能够找到这些。
请提出建议?
我一直在寻找代码分析工具的新想法,编写它们是我的热情所在。因此,如果您能想到我忘记在这些现有工具中检查的其他规则,或任何其他类型的代码分析,请留下评论或给我发送电子邮件,我将尝试将其整合到工具中。