转:FindBugs,第 2 部分: 编写自定义检测器

来源:互联网 发布:和珅 刘墉 知乎 编辑:程序博客网 时间:2024/05/17 15:35
FindBugs,第 2 部分: 编写自定义检测器
如何编写自定义检测器以查找特定于应用程序的问题
FindBugs 是一种可以扩展和定制以满足自己团队独特要求的静态分析工具。在本系列的第 2 部分中,高级软件工程师 Chris Grindstaff 向您展示如何创建特定于应用程序的缺陷检测器。
在本系统的 第一篇文章中,我展示了如何设置和执行 FindBugs。现在我们将分析 FindBugs 最强大的功能——自定义检测器。首先,我将说明为什么自定义缺陷检测器很有用,然后我将引导读者完成一个详细的例子。
编写自定义缺陷检测器
为什么要编写自定义缺陷检测器?我在被要求对一个小组的性能问题进行检查时遇到了这个问题。很显然小组自已开发的日志框架(像所有日志框架一样)随着时间而增大。原来是随意地大量调用Logger。不幸的是,随着小组的扩大,他们的应用程序的性能也在变差,因为他们总是产生昂贵的日志消息——而当日志框架发现禁用日志后,这些消息只能是被它抛弃。解决这个问题的标准方式是在构造昂贵的日志消息之前,首先检查是否启用了日志。换句话说,使用一个像清单 1 这样的监护子句:


清单 1. 监护日志示例
if(Logger.isLogging()) {
    Logger.log("perf", anObjectWithExpensiveToString + anotherExpensiveToString);
}
这个小组认定这是一种恰当的日志做法,并对现有的代码加以改变以体现新的做法。在这个非常大的项目的截止时间快到时听到还有很多地方未改完是不会让人感到意外的。小组需要有更好的方法找出尚未修改的地方。因为本文讨论的是 FindBugs,所以我们将用 FindBugs 解决这个问题。
目标是编写一个 FindBugs 检测器,它将找出代码中调用日志框架而未被包装在监护子句中的所有地方。
当初编写这个检测器时,我将问题分解为几个单独的步骤:
  1. 首先用一条未监护的日志语句编写一个测试案例。
  2. 其次,查看 FindBugs 源代码以查找类似于我要编写的检测器类似的检测器。
  3. 然后创建正确打包的 JAR 文件(使用编译脚本),使 FindBugs 知道如何装载未监护检测器。
  4. 运行这个测试案例并实现代码使测试通过。
  5. 最后,加入更多测试案例,继续这个过程直到最后完成。
在浏览代码时,我特别检查了BytecodeScanningDetectorByteCodePatternDetector的子类型。实现扫描检测器要做更多工作,但是它们能检测更一般类型的问题。如果所要检测的问题可以表述为一组字节码模式,则模式检测器是一种好的选择。它的一个好例子是BCPMethodReturnCheck检测器——它查找那些不同方法的返回类型有可能被忽略的地方。BCPMethodReturnCheck可以很容易地描述为一组模式,它查找在POP或者POP2指令后面调用某些方法的地方。绝大多数检测器目前被编写为扫描检测器,尽管我认为这仅仅是因为开发人员还没有足够的时间转移到ByteCodePatternDetector
我决定使用FindRunInvocations作为例子,主要是因为它是最小的一种检测器。对我来说如何实现使用一组模式的检测器还不是很明确。
FindBugs 利用了 Byte Code Engineering Library,或称为 BCEL (请参阅 参考资料),以实现其检测器。所有字节码扫描检测器都基于 visitor 模式,FindBugs 实现了这个模式。它提供了这些方法的默认实现,在实现自定义检测器时要覆盖这些方法。请分析BetterVisitor及其子类以获得更多细节。出于我们的目的,我们将侧重于两个方法—— visit(Code)sawOpcode(int)。在 FindBugs 分析类时,它会在分析方法内容时调用visit(Code)方法。与此类似,FindBugs 在分析方法正文中的每一个操作码时调用sawOpcode(int)方法。
有了这些背景知识,让我们分析这些用于构建未监护日志检测器的方法的实现,如清单 2 所示:
清单 2. 未监护日志检测器:visit() 方法
18      public void visit(Code code) {
19          seenGuardClauseAt = Integer.MIN_VALUE;
20          logBlockStart = 0;
21          logBlockEnd = 0;
22          super.visit(code);
23      }
 
在读取现有检测器代码时,需要做的一件事是关注检测器是否需要在分析时建立状态。换句话说,检测器是否需要记住它在方法、类、层次结构或者整个程序级别上看到了什么?例如,Inconsistent Synchronization检测器构建整个程序的状态,这样它就可确定在同步方面,什么时候以非一致性的方式对字段进行了访问。我们的检测器只需要在字节码扫描阶段维护状态,因为我们查找的是方法级的问题。
可以在方法visit(Code)中刷新或者重新设置检测器存储的、特定于方法的状态(如清单 2 所示)。在这里,检测器维护了一个使用三位(bit)的状态:
  • seenGuardClauseAt:在所分析的代码中发现日志监护子句时,程序计数器的值
  • logBlockStart:监护子句开始处的索引
  • logBlockEnd:监护子句后面的指令的索引
关于visit(Code)方法的实现有两点很重要。要注意的第一件事是对super.visit()的调用,它是关键,因为这个方法的父类实现负责访问我们要分析的方法的内容。如果我们没有调用父类的实现,那么就不会检查所分析的方法。
第二点是在调用父类的实现之前重新设置存储的状态,这很重要,因为我们将分析的下一个方法—— sawOpcode()方法——将要使用这些变量。我们希望保证在这之前对它们作了重新设置。清单 3 显示了sawOpcode()方法的实现:


清单 3. 未监护日志检测器:sawOpcode() 方法
25      public void sawOpcode(int seen) {
26          if ("cbg/app/Logger".equals(classConstant) &&
27                  seen == INVOKESTATIC &&
28                  "isLogging".equals(nameConstant) && "()Z".equals(sigConstant)) {
29              seenGuardClauseAt = PC;
30              return;
31          }
32          if (seen == IFEQ && (PC >= seenGuardClauseAt + 3 && PC < seenGuardClauseAt + 7)) {
33              logBlockStart = branchFallThrough;
34              logBlockEnd = branchTarget;
35          }
36          if (seen == INVOKEVIRTUAL && "log".equals(nameConstant)) {
37              if (PC < logBlockStart || PC >= logBlockEnd) {
38                  bugReporter.reportBug(
39                          new BugInstance("CBG_UNPROTECTED_LOGGING", HIGH_PRIORITY)
40                          .addClassAndMethod(this).addSourceLine(this));
41              }
42          }
43      }
 
如前所述,当 FindBugs 分析一个方法时,它会对方法中包含的每一个字节码指令调用sawOpcode()。这个方法做三件事。事实上,原来的代码被重构为三个方法,但是在本文的讨论中,我把它排在一行以减少所占用的空间。这个方法做三件事:
  1. 确定是否调用了 static 方法Logger.isLogging(),如果调用了,程序计数器PC的值是什么
  2. 确定在调用Logger.isLogging()后是否有一个if指令
  3. 寻找在监护子句外部调用log()方法的情况
清单 4 更详细地分别显示了每一部分:


清单 4. 未监护日志检测器:调用了 sawOpcode()isLogging()
25      public void sawOpcode(int seen) {
26          if ("cbg/app/Logger".equals(classConstant) &&
27                  seen == INVOKESTATIC &&
28                  "isLogging".equals(nameConstant) && "()Z".equals(sigConstant)) {
29              seenGuardClauseAt = PC;
30              return;
31          }
 
classConstantnameConstantsigConstant字段是检测器从其父类继承的 protected 字段。它们包含有关当前操作码的细节。在编写自己的检测器时,打印出它们的值通常是有用的。浏览BytecodeScanningDetector层次结构以寻找DismantleBytecode类中更有用的字段和方法。另一个编写检测器可以使用的非常有用的工具是永久的javap。对于理解编写检测器的逻辑流程和方法名,Java 反汇编程序是非常有用的工具。一般的方式是编写要查找的模式(在这里是编写 Java 文件中的监护子句)、保存它、再编译它。然后使用javap -c以查看反汇编的字节码,并学习如何构造自己的sawOpcode(int)方法。例如,清单 5 显示了对我的测试实例中使用的类运行javap后的输出(这是一个正确使用日志监护子句的方法):


清单 5. 反汇编的监护子句及源代码
public void methodWithLogging_guarded();
  Code:
   0:   invokestatic    #28; //Method cbg/app/Logger.isLogging:()Z
   3:   ifeq    18
   6:   new     #16; //class Logger
   9:   dup
   10:  invokespecial   #17; //Method cbg/app/Logger."<init>":()V
   13:  ldc     #19; //String bob
   15:  invokevirtual   #23; //Method cbg/app/Logger.log:(Ljava/lang/Object;)V
   18:  aload_0
   19:  invokespecial   #31; //Method doWork:()V
   22:  return
 
   corresponds to the Java source code
   public void methodWithLogging_guarded() {
        if (Logger.isLogging()) {
            new Logger().log("bob");
        }
        doWork();
    }
 
分析javap的输出有助于理解方法的控制流程序以及如何构建需要在sawOpcode()方法中指定的类、签名和名称常量。例如,清单 6 显示清单 5 javap的第一行代码


清单 6. 反汇编的方法调用
0:   invokestatic    #28; //Method cbg/app/Logger.isLogging:()Z
 
如果仔细观察 清单 4 sawOpcode()方法的第 26 28 行,将会看到它们描述了一种与我们在 清单 5 中用javap见到的内容相匹配的方式。在确定如何匹配这些格式(form )时,javap是一个有用的工具。
确定已经调用了Logger.isLogging()方法后,我们要保存程序计数器的值,如清单 7 所示。需要用程序计数器确定在调用Logger.isLogging()后,是否有一个if子句,这将我们带入下一节的代码。


清单 7. 保存程序计数器的值
32          if (seen == IFEQ && (PC >= seenGuardClauseAt + 3 && PC < seenGuardClauseAt + 7)) {
33              logBlockStart = branchFallThrough;
34              logBlockEnd = branchTarget;
35          }
 
这段摘自 清单 3 的代码检查在上述对Logger.isLogging()的调用后面 3 7 字节码之间是否有if分支语句。这些值是通过查看javap的输出和通过试验确定的。你是说试验?没错,有些时候必须借助于反复试验才能找到伪错误和有用的结果之间的平衡点。将这个过程想像为使用试探法的计算机工程而不是计算机科学。确定了这个语句是if(Logger.isLogging())语句后,我们需要找出if代码块的边界。这是通过保存branchFallThroughbranchTarget而做到的。branchFallThroughif子句的开始,而branchTarget代表if子句外面的第一行。有了这些信息,我们现在就可以进入这个方法的最后一部分了,如清单 8 所示:


清单 8. 检查对 log() 的调用
36          if (seen == INVOKEVIRTUAL && "log".equals(nameConstant)) {
37              if (PC < logBlockStart || PC >= logBlockEnd) {
38                  bugReporter.reportBug(
39                          new BugInstance("CBG_UNPROTECTED_LOGGING", HIGH_PRIORITY)
40                          .addClassAndMethod(this).addSourceLine(this));
41              }
42          }
 
这段代码也取自 清单 3,查找对Loggerlog()方法的调用。找到对log()方法的调用后,我们检查程序计数器是否在前面确定的if块外面。如果是的话,我们就通过创建一个新的 bug 实例报告一个缺陷,指定 bug 的类型(我们将在后面详细讨论)和其优先级。在 bug 中加入类、方法和源代码会提供很大方便,这样用户就知道在什么地方修复这个问题。
编写了代码后,需要创建一个特别打包的 JAR 文件,FindBugs 将它识别为插件程序 JAR。清单 9 显示了我用来创建这个 JAR 文件并将它拷贝到正确位置的编译脚本:


清单 9. 打包 FindBugs 检测器的脚本
<property name="FindBugs.home" value="C:/apps/FindBugs-0.7.3"></property>
<target name="build">
         <jar destfile="cbgFindbugsPlugin.jar">
                 <fileset dir="bin"/>
                 <fileset dir="src"/>
                 <zipfileset dir="etc" includes="*.xml" prefix=""></zipfileset>
         </jar>
         <copy file="cbgFindbugsPlugin.jar" todir="${FindBugs.home}/plugin" />
</target>
 
这段代码创建一个包含源文件、类文件、FindBugs.xml message.xml JAR 文件。清单 10 11 显示了这两个 XML 文件的内容:


清单 10. FindBugs.xml 的内容
<FindbugsPlugin>
  <Detector class="cbg.FindBugs.FindUnprotectedLogging" speed="fast" />
  <BugPattern abbrev="CBGL" type="CBG_UNPROTECTED_LOGGING" category="PERFORMANCE" />
</FindbugsPlugin>
 
对于每一个新的检测器,在 FindBugs.xml 文件中增加一个Detector元素和一个BugPattern元素。Detector元素指定用于实现检测器的类以及它是快速还是慢速检测器。在 UI 中查看检测器时就会用到 speed 属性,如图 1 所示。speed 属性的可能值有 slowmoderate fast



BugPattern元素指定三个属性。abbrev属性定义检测器的缩写。缩写用于标识用命令行客户运行时检测到的缺陷。可以用同一个缩写将几个相关的检测器组织到一起。.
type属性是惟一标识符,有两个用途。在使用 Ant 版本或者命令行版本的 FindBugs 且输出格式设置为 XML 时,用type属性标识问题。type属性也是在检测器的 Java 代码中指定的,用以创建缺陷的正确类型。注意这里列出的类型与在 清单 8中第 39 行使用的名字相匹配。.
category属性是枚举类型。它是以下类型中的一种:
  • CORRECTNESS:一般正确性问题
  • MT_CORRECTNESS:多线程正确性问题
  • MALICIOUS_CODE:如果公开给恶意代码,有可能成为攻击点
  • PERFORMANCE:性能问题
FindBugs.xml 文件就是这些了。清单 11 显示了 messages.xml 文件的内容:


清单 11. messages.xml 的内容
<MessageCollection>
  <Detector class="cbg.FindBugs.FindUnprotectedLogging">
    <Details>
<![CDATA[
<p> This detector finds logs statements that aren't contained in an if-logging block.
It is a fast detector.
]]>
    </Details>
  </Detector>
  <BugPattern type="CBG_UNPROTECTED_LOGGING">
    <ShortDescription>Found unprotected logging</ShortDescription>
    <LongDescription>Found unprotected logging in {1}</LongDescription>
    <Details>
<![CDATA[
<p> This method logs without first checking that logging is enabled; for example
... more text omitted...
]]>
    </Details>
  </BugPattern>
 
  <BugCode abbrev="CBGL">Found unprotected logging</BugCode>
</MessageCollection>
 
messages.xml 文件由三个元素组成:DetectorBugPatternBugCode
检测器的class属性应当指定检测器的类名。Details元素包含检测器的简单 HTML 描述,因而应当包含在CDATA部分中。UI 使用这些描述,如图 2 所示:


2. FindBugs UI 突出显示未监护日志检测器
BugPattern元素类似于在 FindBugs.xml 中定义的BugPattern元素。需要type属性,并且它应当匹配在 FindBugs.xml 和在检测器的 Java 代码中使用的相同惟一标识符。BugPattern包含三个影响有关检测器的信息在 UI 中显示方式的元素:ShortDescriptionLongDescriptionDetails ——它们的意义都是相当直观的。
UI 中关闭 View > Full Descriptions 时,使用ShortDescription。同样,在启用 View > Full Descriptions 时,使用LongDescription。可以使用注释(annotation)将信息从缺陷检测器的 Java 代码中传递给完全描述。在描述中,用{0}表示第一个注释、{1}表示第二个注释等来指定变量。在运行时,如果发现缺陷,附加在缺陷实例上的注释将替换到描述中。注意在 清单 8 的第 40 行,类和方法注释添加到了BugInstance上。类注释在位置 0,方法注释在位置 1。更多细节请查看BugInstance上的不同add*()方法。
与前面一样,Details元素应当在CDATA部分中包含一个 HTML 描述。图 2 显示了我们的检测器细节的一个例子。View > Full Descriptions 已经打开。
在使用 By Bug Type 选项卡时,UI 使用BugCode元素。这个元素的文字在树中作为红色节点出现,如 2 所示。公共检测器共享同一个缩写,因此BugCode元素必须用元素的属性指定这个缩写。
创建了这两个 XML 文件后,我们现在就可以打包完整的 JAR 了。在编译了 JAR 并将它放到 FIND_BUGS_HOME/plugin 目录中后,就可以测试新的检测器了。
特定于应用程序的缺陷检测器
FindBugs 会是您的装备库中一件有用的工具。但是,像所有工具一样,必须知道如何使用它。不过,静态分析工具应作为单元/系统测试和代码审查的补充。
除了其改进代码质量的作用,FindBugs 还有很多特定于应用程序的用法,我鼓励读者去探索它们。例如,可以编写一组检测器,它们可以查找新手容易出现的问题。也可以编写检查代码是否符合小组规则的检测器。也许您正在构建一个框架,并且需要保证包中的所有类都有零参数的构造函数、或者所有带下划线前缀的字段都有 getter 而没有 setter。也许可以编写一组检测器,它们验证 J2EE 代码遵守适当的限制,如不创建Thread或者Socket
未监护日志例子中的小组还有捕获异常的问题。值得称赞的是,他们没有简单忽略这些异常,相反,他们让它们打印自己的堆栈跟踪,这对于编译和调试应用程序很好,但是对于部署来说这不是很理想——特别是当可能有数千个异常时。(当然,如果应用程序抛出数千个异常,您的问题就比大的日志文件要严重得多了,但是姑且容许我为了说明问题而这样说。)小组需要一个检测器来找出代码中捕获异常并要求打印其堆栈跟踪的地方。这样他们就可以改变代码,将异常改为传递给日志框架。
我创建了另外一个有趣的未监护日志检测器。这个检测器用于寻找代码中所有在监护子句以外生成要记录消息的地方——如果使用了行为有些特别的toString,这会是一个非常常见的问题,并且代价有可能相当昂贵。
结束语
不管是刚接触 FindBugs 还是已经熟悉它了,我鼓励您用自己的特定于应用程序的检测器进行试验。同时,我希望本文提供如何实现自定义检测器的简洁例子,并鼓励您将这些思路应用到小组的特定情况中去。
参考资料
  • 您可以参阅本文在 developerWorks 全球站点上的 英文原文.
  • 下载最新版本的 FindBugs
  • FindBugs 网站提供了完整的 缺陷清单及说明
  • 阅读有关 Visitor 模式的更多信息。
  • 这里有关于 字节码设计库(Byte Code Engineering Library的更多信息。
  • 可以在论文“ Finding Bugs is Easy”及其源代码中找到更多关于 FindBugs 的细节。
  • PMD是另一个强大的开放源代码静态代码分析工具,它可以让您编写自定义规则。它不像 FindBugs 那么强大,因为它分析 Java 文件而不是类文件,但是仍然值得了解。
  • 两位作者试图描述避免 FindBugs 可以检测到的那些问题的一组最佳实践:Joshua Bloch Effective Java: Programming Language Guide Addison-Wesley2001 年)和 Peter Haggar Practical Java: Programming Language Guide Addison-Wesley2000 年)。
  • Mark Roulo 在其文章“ Complement testing with code inspections” developerWorks2000 3 月)中讨论了通过代码检查可以检测到的常见错误。其中一些错误也可由 FindBugs 检测。
  • 请参阅 Eric 诊断 Java 代码系列了解常见的缺陷模式。
  • developerWorksJava 技术专区找到数百篇 Java 技术资料。
  • 请访问 Developer Bookstore,获取技术书籍的完整清单,其中包括数百本 Java 相关主题的书籍。
关于作者
Chris Grindstaff 是在北加利福尼亚 Research Triangle Park 工作的 IBM 高级软件工程师。Chris 7 岁时编写了他的第一个程序,当时他让小学老师认识到键入句子与手写它们一样费力。Chris 目前参与了不同的开放源代码项目。他大量使用 Eclipse 并编写了几个流行的 Eclipse 插件程序,可以在他的 网站找到这些插件程序。可以通过 cgrinds@us.ibm.com或者 chris@gstaff.org Chrise 联系。
 
1. 配置检测器 UI