Chris Grindstaff
软件工程师, IBM
25 日 5 月 2004 年
静态分析工具承诺无需开发人员费劲就能找出代码中已有的缺陷。当然,如果有多年的编写经验,就会知道这些承诺并不是一定能兑现。尽管如此,好的静态分析工具仍然是工具箱中的无价之宝。在这个由两部分组成的系列文章的第一部分中,高级软件工程师 Chris Grindstaff 分析了 FindBugs 如何帮助提高代码质量以及排除隐含的缺陷。
代码质量工具的一个问题是它们容易为开发人员提供大量但并非真正问题的问题——即 伪问题(false positives)。出现伪问题时,开发人员要学会忽略工具的输出或者放弃它。FindBugs 的设计者 David Hovemeyer 和 William Pugh 注意到了这个问题,并努力减少他们所报告的伪问题数量。与其他静态分析工具不同,FindBugs 不注重样式或者格式,它试图只寻找真正的缺陷或者潜在的性能问题。
FindBugs 是什么?
FindBugs 是一个静态分析工具,它检查类或者 JAR 文件,将字节码与一组缺陷模式进行对比以发现可能的问题。有了静态分析工具,就可以在不实际运行程序的情况对软件进行分析。不是通过分析类文件的形式或结构来确定程序的意图,而是通常使用 Visitor 模式(请参阅 参考资料)。图 1 显示了分析一个匿名项目的结果(为防止可怕的犯罪,这里不给出它的名字):
图 1. FindBugs UI
让我们看几个 FindBugs 可以发现的问题。
本系列的第二篇文章“编写自定义检测器”解释了如何编写自定义检测器, 以便发现特定于应用程序的问题。
|
问题发现的例子
下面的列表没有包括 FindBug 可以找到的 所有 问题。相反,我侧重于一些更有意思的问题。
检测器:找出 hash equals 不匹配
这个检测器寻找与 equals()
和 hashCode()
的实现相关的几个问题。这两个方法非常重要,因为几乎所有基于集合的类—— List、Map、Set 等都调用它们。一般来说,这个检测器寻找两种不同类型的问题——当一个类:
- 重写对象的
equals()
方法,但是没有重写它的 hashCode
方法,或者相反的情况时。
- 定义一个 co-variant 版本的
equals()
或 compareTo()
方法。例如,Bob
类定义其 equals()
方法为布尔 equals(Bob)
,它覆盖了对象中定义的 equals()
方法。因为 Java 代码在编译时解析重载方法的方式,在运行时使用的几乎总是在对象中定义的这个版本的方法,而不是在 Bob
中定义的那一个(除非显式将 equals()
方法的参数强制转换为 Bob
类型)。因此,当这个类的一个实例放入到类集合中的任何一个中时,使用的是 Object.equals()
版本的方法,而不是在 Bob
中定义的版本。在这种情况下,Bob
类应当定义一个接受类型为 Object
的参数的 equals()
方法。
检测器:忽略方法返回值
这个检测器查找代码中忽略了不应该忽略的方法返回值的地方。这种情况的一个常见例子是在调用 String
方法时,如在清单 1 中:
清单 1. 忽略返回值的例子
1 String aString = "bob";
2 b.replace('b', 'p');
3 if(b.equals("pop"))
|
这个错误很常见。在第 2 行,程序员认为他已经用 p 替换了字符串中的所有 b。确实是这样,但是他忘记了字符串是不可变的。所有这类方法都返回一个新字符串,而从来不会改变消息的接收者。
检测器:Null 指针对 null 的解引用(dereference)和冗余比较
这个检测器查找两类问题。它查找代码路径将会或者可能造成 null 指针异常的情况,它还查找对 null 的冗余比较的情况。例如,如果两个比较值都为 null,那么它们就是冗余的并可能表明代码错误。FindBugs 在可以确定一个值为 null 而另一个值不为 null 时,检测类似的错误,如清单 2 所示:
清单 2. Null 指针示例
1 Person person = aMap.get("bob");
2 if (person != null) {
3 person.updateAccessTime();
4 }
5 String name = person.getName();
|
在这个例子中,如果第 1 行的 Map
不包括一个名为“bob”的人,那么在第 5 行询问 person
的名字时就会出现 null 指针异常。因为 FindBugs 不知道 map 是否包含“bob”,所以它将第 5 行标记为可能 null 指针异常。
检测器:初始化之前读取字段
这个检测器寻找在构造函数中初始化之前被读取的字段。这个错误通常是——尽管不总是如此——由使用字段名而不是构造函数参数引起的,如清单 3 所示:
清单 3. 在构造函数中读取未初始化的字段
1 public class Thing {
2 private List actions;
3 public Thing(String startingActions) {
4 StringTokenizer tokenizer = new StringTokenizer(startingActions);
5 while (tokenizer.hasMoreTokens()) {
6 actions.add(tokenizer.nextToken());
7 }
8 }
9 }
|
在这个例子中,第 6 行将产生一个 null 指针异常,因为变量 actions
还没有初始化。
这些例子只是 FindBugs 所发现的问题种类的一小部分(更多信息请参阅 参考资料)。在撰写本文时,FindBugs 提供总共 35 个检测器。
开始使用 FindBugs
要运行 FindBugs,需要一个版本 1.4 或者更高的 Java Development Kit (JDK),尽管它可以分析由老的 JDK 创建的类文件。要做的第一件事是下载并安装最新发布的 FindBugs——当前是 0.7.1 (请参阅 参考资料)。幸运的是,下载和安全是相当简单的。在下载了 zip 或者 tar 文件后,将它解压缩到所选的目录中。就是这样了——安装就完成了。
安装完后,对一个示例类运行它。就像一般文章中的情况,我将针对 Windows 用户进行讲解,并假定那些 Unix 信仰者可以熟练地转化这些内容并跟进。打开命令行提示符号并进入 FindBugs 的安装目录。对我来说,这是 C:\apps\FindBugs-0.7.3。
在 FindBugs 主目录中,有几个值得注意的目录。文档在 doc 目录中,但是对我们来说更重要的是,bin 目录包含了运行 FindBugs 的批处理文件,这使我们进入下一部分。
运行 FindBugs
像如今的大多数数工具一样,可以以多种方式运行 FindBugs——从 GUI、从命令行、使用 Ant、作为 Eclipse 插件程序和使用 Maven。我将简要提及从 GUI 运行 FindBugs,但是重点放在用 Ant 和命令行运行它。部分原因是由于 GUI 没有提供命令行的所有选项。例如,当前不能指定要加入的过滤器或者在 UI 中排除特定的类。但是更重要的原因是我认为 FindBugs 最好作为编译的集成部分使用,而 UI 不属于自动编译。
使用 FindBugs UI
使用 FindBugs UI 很直观,但是有几点值得说明。如 图 1 所示,使用 FindBugs UI 的一个好处是对每一个检测到的问题提供了说明。图 1 显示了缺陷 Naked notify in method 的说明。对每一种缺陷模式提供了类似的说明,在第一次熟悉这种工具时这是很有用的。窗口下面的 Source code 选项卡也同样有用。如果告诉 FindBugs 在什么地方寻找代码,它就会在转换到相应的选项卡时突出显示有问题的那一行。
值得一提的还有在将 FinBugs 作为 Ant 任务或者在命令行中运行 FindBugs 时,选择 xml
作为 ouput
选项,可以将上一次运行的结果装载到 UI 中。这样做是同时利用基于命令行的工具和 UI 工具的优点的一个很好的方法。
将 FindBugs 作为 Ant 任务运行
让我们看一下如何在 Ant 编译脚本中使用 FindBugs。首先将 FindBugs Ant 任务拷贝到 Ant 的 lib 目录中,这样 Ant 就知道新的任务。将 FIND_BUGS_HOME\lib\FindBugs-ant.jar 拷贝到 ANT_HOME\lib。
现在看看在编译脚本中要加入什么才能使用 FindBugs 任务。因为 FindBugs 是一个自定义任务,将需要使用 taskdef
任务以使 Ant 知道装载哪一个类。通过在编译文件中加入以下一行做到这一点:
<taskdef name="FindBugs" classname="edu.umd.cs.FindBugs.anttask.FindBugsTask"/>
|
在定义了 taskdef
后,可以用它的名字 FindBugs
引用它。下一步要在编译中加入使用新任务的目标,如清单 4 所示:
清单 4. 创建 FindBugs 目录
1 <target name="FindBugs" depends="compile">
2 <FindBugs home="${FindBugs.home}" output="xml" outputFile="jedit-output.xml">
3 <class location="c:\apps\JEdit4.1\jedit.jar" />
4 <auxClasspath path="${basedir}/lib/Regex.jar" />
5 <sourcePath path="c:\tempcbg\jedit" />
6 </FindBugs>
7 </target>
|
让我们更详细地分析这段代码中所发生的过程。
第 1 行: 注意 target
取决于编译。一定要记住处理的是类文件而 不 是源文件,这样使 target
对应于编译目标保证了 FindBugs 可在最新的类文件运行。FindBugs 可以灵活地接受多种输入,包括一组类文件、JAR 文件、或者一组目录。
第 2 行: 必须指定包含 FindBugs 的目录,我是用 Ant 的一个属性完成的,像这样:
<property name="FindBugs.home" value="C:\apps\FindBugs-0.7.3" />
|
可选属性 output
指定 FindBugs 的结果使用的输出格式。可能的值有 xml
、text
或者 emacs
。如果没有指定 outputFile
,那么 FindBugs 会使用标准输出。如前所述,XML 格式有可以在 UI 中观看的额外好处。
第 3 行:
class
元素用于指定要 FindBugs 分析哪些 JAR、类文件或者目录。分析多个 JAR 或者类文件时,要为每一个文件指定一个单独的 class
元素。除非加入了 projectFile
元素,否则需要 class
元素。更多细节请参阅 FindBugs 手册。
第 4 行: 用嵌套元素 auxClasspath
列出应用程序的依赖性。这些是应用程序需要但是不希望 FindBugs 分析的类。如果没有列出应用程序的依赖关系,那么 FindBugs 仍然会尽可能地分析类,但是在找不到一个缺少的类时,它会抱怨。与 class
元素一样,可以在 FindBugs 元素中指定多个 auxClasspath
元素。auxClasspath
元素是可选的。
第 5 行: 如果指定了 sourcePath
元素,那么 path
属性应当表明一个包含应用程序源代码的目录。指定目录使 FindBugs 可以在 GUI 中查看 XML 结果时突出显示出错的源代码。这个元素是可选的。
上面就是基本内容了。让我们提前几个星期。
过滤器
您已经将 FindBugs 引入到了团队中,并运行它作为您的每小时/每晚编译过程的一部分。当团队越来越熟悉这个工具时,出于某些原因,您决定所检测到的一些缺陷对于团队来说不重要。也许您不关心一些类是否返回可能被恶意修改的对象——也许,像 JEdit,有一个真正需要的(honest-to-goodness)、合法的理由调用 System.gc()
。
总是可以选择“关闭”特定的检测器。在更细化的水平上,可以在指定的一组类甚至是方法中查找问题时,排除某些检测器。FindBugs 提供了这种细化的控制,可以排除或者包含过滤器。当前只有用命令行或者 Ant 启动的 FindBugs 中支持排除和包含过滤器。正如其名字所表明的,使用排除过滤器来排除对某些缺陷的报告。较为少见但仍然有用的是,包含过滤器只能用于报告指定的缺陷。过滤器是在一个 XML 文件中定义的。可以在命令行中用一个排除或者包含开关、或者在 Ant 编译文件中用 excludeFilter
和 includeFilter
指定它们。在下面的例子中,假定使用排除开关。还要注意在下面的讨论中,我对 “bugcode”、“bug” 和“detector”的使用具有某种程度的互换性。
可以有不同的方式定义过滤器:
- 匹配一个类的过滤器。可以用这些过滤器 忽略在特定类中发现的所有问题。
- 匹配一个类中特定缺陷代码(bugcode)的 过滤器。可以用这些过滤器忽略在特定类中发现的一些缺陷。
- 匹配一组缺陷的过滤器。可以用这些过滤器 忽略所分析的所有类中的一组缺陷。
- 匹配所分析的一个类中的某些方法的过滤器。可以用这些过滤器忽略在一个类中的一组方法中发现的所有缺陷。
- 匹配在所分析的一个类中的方法中发现的某些缺陷的过滤器。可以用这些过滤器忽略在一组方法中发现的特定缺陷。
知道了这些就可以开始使用了。有关其他定制 FindBugs 方法的更多信息,请参阅 FindBugs 文档。知道如何设置编译文件以后,就让我们更详细地分析如何将 FindBugs 集成到编译过程中吧!
将 FindBugs 集成到编译过程中
在将 FindBugs 集成到编译过程当中可以有几种选择。总是可以在命令行执行 FindBugs,但是您很可能已经使用 Ant 进行编译,所以最自然的方法是使用 FindBugs Ant 任务。因为我们在 如何运行 FindBugs 一节中讨论了使用 FindBugs Ant 任务的基本内容,所以现在讨论应当将 FindBugs 加入到编译过程中的几个理由,并讨论几个可能遇到的问题。
为什么应该将 FindBugs 集成到编译过程中?
经常问到的第一个问题是为什么要将 FindBugs 加入到编译过程中?虽然有大量理由,最明显的回答是要保证尽可能早地在进行编译时发现问题。当团队扩大,并且不可避免地在项目中加入更多新开发人员时,FindBugs 可以作为一个安全网,检测出已经识别的缺陷模式。我想重申在一篇 FindBugs 论文中表述的一些观点。如果让一定数量的开发人员共同工作,那么在代码中就会出现缺陷。像 FindBugs 这样的工具当然不会找出所有的缺陷,但是它们会帮助找出其中的部分。现在找出部分比客户在以后找到它们要好——特别是当将 FindBugs 结合到编译过程中的成本是如此低时。
一旦确定了加入哪些过滤器和类,运行 FindBugs 就没什么成本了,而带来的好处就是它会检测出新缺陷。如果编写特定于应用程序的检测器,则这个好处可能更大。
生成有意义的结果
重要的是要认识到这种成本/效益分析只有在不生成大量误检时才有效。换句话说,如果在每次编译时,不能简单地确定是否引入了新的缺陷,那么这个工具的价值就会被抵消。分析越自动化越好。如果修复缺陷意味着必须吃力地分析检测出的大量不相干的缺陷,那么您就不会经常使用它,或者至少不会很好地使用它。
确定不关心哪些问题并从编译中排除它们。也可以挑出 确实 关注的一小部分检测器并只运行它们。另一种选择是从个别的类中排除一组检测器,但是其他的类不排除。FindBugs 提供了使用过滤器的极大灵活性,这可帮助生成对团队有意义的结果,由此我们进入下一节。
确定用 FindBugs 的结果做什么
可能看来很显然,但是您想不到我参与的团队中有多少加入了类似 FindBugs 这样的工具而没有真正利用它。让我们更深入地探讨这个问题——用结果做什么?明确回答这个问题是困难的,因为这与团队的组织方式、如何处理代码所有权问题等有很大关系。不过,下面是一些指导:
- 可以考虑将 FindBugs 结果加入到源代码管理(SCM)系统中。一般的经验做法是不将编译工件(artifact)放到 SCM 系统中。不过,在这种特定情况下,打破这个规则可能是正确的,因为它使您可以监视代码质量随时间的变化。
- 可以选择将 XML 结果转换为可以发送到团队的网站上的 HTML 报告。转换可以用 XSL 样式表或者脚本实现。有关例子请查看 FindBugs 网站或者邮件列表(请参阅 参考资料)。
- 像 FindBugs 这样的工具通常会成为用于敲打团队或者个人的政治武器。尽量抵制这种做法或者不让它发生——记住,它只是一个工具,它可以帮助改进代码的质量。有了这种思想,在下一部分中,我将展示如何编写自定义缺陷检测器。
结束语
我鼓励读者对自己的代码试用静态分析工具,不管是 FindBugs、PMD 还是其他的。它们是有用的工具,可以找出真正的问题,而 FindBugs 是在消除误检方面做得最好的工具。此外,它的可插入结构提供了编写有价值的、特定于应用程序的检测器的、有意思的测试框架。在本系列的 第 2 部分 中,我将展示如何编写自定义检测器以找出特定于应用程序的问题。
参考资料
关于作者 Chris Grindstaff 是在北加利福尼亚 Research Triangle Park 工作的 IBM 高级软件工程师。Chris 在 7 岁时编写了他的第一个程序,当时他让小学老师认识到“键入”句子与手写它们一样费力。Chris 目前参与了不同的开放源代码项目。他大量使用 Eclipse 并编写了几个流行的 Eclipse 插件程序,可以在他的 网站 找到这些插件程序。可以通过 cgrinds@us.ibm.com 或者 chris@gstaff.org 与 Chrise 联系。 |
转自IBM.CN