【IT168 技术文章】为什么要编写自定义缺陷检测器?我在被要求对一个小组的性能问题进行检查时遇到了这个问题。很显然小组自已开发的日志框架(像所有日志框架一样)随着时间而增大。原来是随意地大量调用 Logger 。不幸的是,随着小组的扩大,他们的应用程序的性能也在变差,因为他们总是产生昂贵的日志消息——而当日志框架发现禁用日志后,这些消息只能是被它抛弃。解决这个问题的标准方式是在构造昂贵的日志消息之前,首先检查是否启用了日志。换句话说,使用一个像清单 1 这样的监护子句:
清单 1. 监护日志示例
2 Logger.log("perf", anObjectWithExpensiveToString + anotherExpensiveToString);
3 }
4
这个小组认定这是一种恰当的日志做法,并对现有的代码加以改变以体现新的做法。在这个非常大的项目的截止时间快到时听到还有很多地方未改完是不会让人感到意外的。小组需要有更好的方法找出尚未修改的地方。因为本文讨论的是 FindBugs,所以我们将用 FindBugs 解决这个问题。
目标是编写一个 FindBugs 检测器,它将找出代码中调用日志框架而未被包装在监护子句中的所有地方。
当初编写这个检测器时,我将问题分解为几个单独的步骤:
首先用一条未监护的日志语句编写一个测试案例。
其次,查看 FindBugs 源代码以查找类似于我要编写的检测器类似的检测器。
然后创建正确打包的 JAR 文件(使用编译脚本),使 FindBugs 知道如何装载未监护检测器。
运行这个测试案例并实现代码使测试通过。
最后,加入更多测试案例,继续这个过程直到最后完成。
在浏览代码时,我特别检查了 BytecodeScanningDetector 和 ByteCodePatternDetector 的子类型。实现扫描检测器要做更多工作,但是它们能检测更一般类型的问题。如果所要检测的问题可以表述为一组字节码模式,则模式检测器是一种好的选择。它的一个好例子是 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() 方法
2 19 seenGuardClauseAt = Integer.MIN_VALUE;
3 20 logBlockStart = 0;
4 21 logBlockEnd = 0;
5 22 super.visit(code);
6 23 }
7
在读取现有检测器代码时,需要做的一件事是关注检测器是否需要在分析时建立状态。换句话说,检测器是否需要记住它在方法、类、层次结构或者整个程序级别上看到了什么?例如, Inconsistent Synchronization 检测器构建整个程序的状态,这样它就可确定在同步方面,什么时候以非一致性的方式对字段进行了访问。我们的检测器只需要在字节码扫描阶段维护状态,因为我们查找的是方法级的问题。
可以在方法 visit(Code) 中刷新或者重新设置检测器存储的、特定于方法的状态(如清单 2 所示)。在这里,检测器维护了一个使用三位(bit)的状态:
seenGuardClauseAt :在所分析的代码中发现日志监护子句时,程序计数器的值
logBlockStart :监护子句开始处的索引
logBlockEnd :监护子句后面的指令的索引
关于 visit(Code) 方法的实现有两点很重要。要注意的第一件事是对 super.visit() 的调用,它是关键,因为这个方法的父类实现负责访问我们要分析的方法的内容。如果我们没有调用父类的实现,那么就不会检查所分析的方法。
第二点是在调用父类的实现之前重新设置存储的状态,这很重要,因为我们将分析的下一个方法—— sawOpcode() 方法——将要使用这些变量。我们希望保证在这之前对它们作了重新设置。清单 3 显示了 sawOpcode() 方法的实现:
清单 3. 未监护日志检测器:sawOpcode() 方法
2 26 if ("cbg/app/Logger".equals(classConstant) &&
3 27 seen == INVOKESTATIC &&
4 28 "isLogging".equals(nameConstant) && "()Z".equals(sigConstant)) {
5 29 seenGuardClauseAt = PC;
6 30 return;
7 31 }
8 32 if (seen == IFEQ && (PC >= seenGuardClauseAt + 3 && PC < seenGuardClauseAt + 7)) {
9 33 logBlockStart = branchFallThrough;
10 34 logBlockEnd = branchTarget;
11 35 }
12 36 if (seen == INVOKEVIRTUAL && "log".equals(nameConstant)) {
13 37 if (PC < logBlockStart || PC >= logBlockEnd) {
14 38 bugReporter.reportBug(
15 39 new BugInstance("CBG_UNPROTECTED_LOGGING", HIGH_PRIORITY)
16 40 .addClassAndMethod(this).addSourceLine(this));
17 41 }
18 42 }
19 43 }
20
如前所述,当 FindBugs 分析一个方法时,它会对方法中包含的每一个字节码指令调用 sawOpcode() 。这个方法做三件事。事实上,原来的代码被重构为三个方法,但是在本文的讨论中,我把它排在一行以减少所占用的空间。这个方法做三件事:
确定是否调用了 static 方法 Logger.isLogging() ,如果调用了,程序计数器 (PC) 的值是什么
确定在调用 Logger.isLogging() 后是否有一个 if 指令
寻找在监护子句外部调用 log() 方法的情况
清单 4 更详细地分别显示了每一部分:
清单 4. 未监护日志检测器:调用了 sawOpcode()、isLogging()
2 26 if ("cbg/app/Logger".equals(classConstant) &&
3 27 seen == INVOKESTATIC &&
4 28 "isLogging".equals(nameConstant) && "()Z".equals(sigConstant)) {
5 29 seenGuardClauseAt = PC;
6 30 return;
7 31 }
8
classConstant 、 nameConstant 和 sigConstant 字段是检测器从其父类继承的 protected 字段。它们包含有关当前操作码的细节。在编写自己的检测器时,打印出它们的值通常是有用的。浏览 BytecodeScanningDetector 层次结构以寻找 DismantleBytecode 类中更有用的字段和方法。另一个编写检测器可以使用的非常有用的工具是永久的 javap 。对于理解编写检测器的逻辑流程和方法名,Java 反汇编程序是非常有用的工具。一般的方式是编写要查找的模式(在这里是编写 Java 文件中的监护子句)、保存它、再编译它。然后使用 javap -c 以查看反汇编的字节码,并学习如何构造自己的 sawOpcode(int) 方法。例如,清单 5 显示了对我的测试实例中使用的类运行 javap 后的输出(这是一个正确使用日志监护子句的方法):
清单 5. 反汇编的监护子句及源代码
2 Code:
3 0: invokestatic #28; //Method cbg/app/Logger.isLogging:()Z
4 3: ifeq 18
5 6: new #16; //class Logger
6 9: dup
7 10: invokespecial #17; //Method cbg/app/Logger."<init>":()V
8 13: ldc #19; //String bob
9 15: invokevirtual #23; //Method cbg/app/Logger.log:(Ljava/lang/Object;)V
10 18: aload_0
11 19: invokespecial #31; //Method doWork:()V
12 22: return
13
14 corresponds to the Java source code
15 public void methodWithLogging_guarded() {
16 if (Logger.isLogging()) {
17 new Logger().log("bob");
18 }
19 doWork();
20 }
21
分析 javap 的输出有助于理解方法的控制流程序以及如何构建需要在 sawOpcode() 方法中指定的类、签名和名称常量。例如,清单 6 显示清单 5 中 javap 的第一行代码
清单 6. 反汇编的方法调用
2
如果仔细观察 清单 4 中 sawOpcode() 方法的第 26 到 28 行,将会看到它们描述了一种与我们在 清单 5 中用 javap 见到的内容相匹配的方式。在确定如何匹配这些格式(form )时, javap 是一个有用的工具。
确定已经调用了 Logger.isLogging() 方法后,我们要保存程序计数器的值,如清单 7 所示。需要用程序计数器确定在调用 Logger.isLogging() 后,是否有一个 if 子句,这将我们带入下一节的代码。
清单 7. 保存程序计数器的值
2 33 logBlockStart = branchFallThrough;
3 34 logBlockEnd = branchTarget;
4 35 }
5
这段摘自 清单 3 的代码检查在上述对 Logger.isLogging() 的调用后面 3 到 7 字节码之间是否有 if 分支语句。这些值是通过查看 javap 的输出和通过试验确定的。你是说试验?没错,有些时候必须借助于反复试验才能找到伪错误和有用的结果之间的平衡点。将这个过程想像为使用试探法的计算机工程而不是计算机科学。确定了这个语句是 if(Logger.isLogging()) 语句后,我们需要找出 if 代码块的边界。这是通过保存 branchFallThrough 和 branchTarget 而做到的。 branchFallThrough 是 if 子句的开始,而 branchTarget 代表 if 子句外面的第一行。有了这些信息,我们现在就可以进入这个方法的最后一部分了,如清单 8 所示:
清单 8. 检查对 log() 的调用
2 37 if (PC < logBlockStart || PC >= logBlockEnd) {
3 38 bugReporter.reportBug(
4 39 new BugInstance("CBG_UNPROTECTED_LOGGING", HIGH_PRIORITY)
5 40 .addClassAndMethod(this).addSourceLine(this));
6 41 }
7 42 }
8
这段代码也取自 清单 3,查找对 Logger 的 log() 方法的调用。找到对 log() 方法的调用后,我们检查程序计数器是否在前面确定的 if 块外面。如果是的话,我们就通过创建一个新的 bug 实例报告一个缺陷,指定 bug 的类型(我们将在后面详细讨论)和其优先级。在 bug 中加入类、方法和源代码会提供很大方便,这样用户就知道在什么地方修复这个问题。
编写了代码后,需要创建一个特别打包的 JAR 文件,FindBugs 将它识别为插件程序 JAR。清单 9 显示了我用来创建这个 JAR 文件并将它拷贝到正确位置的编译脚本:
清单 9. 打包 FindBugs 检测器的脚本
2 <target name="build">
3 <jar destfile="cbgFindbugsPlugin.jar">
4 <fileset dir="bin"/>
5 <fileset dir="src"/>
6 <zipfileset dir="etc" includes="*.xml" prefix=""></zipfileset>
7 </jar>
8 <copy file="cbgFindbugsPlugin.jar" todir="${FindBugs.home}/plugin" />
9 </target>
10
这段代码创建一个包含源文件、类文件、FindBugs.xml 和 message.xml 的 JAR 文件。清单 10 和 11 显示了这两个 XML 文件的内容:
清单 10. FindBugs.xml 的内容
2 <Detector class="cbg.FindBugs.FindUnprotectedLogging" speed="fast" />
3 <BugPattern abbrev="CBGL" type="CBG_UNPROTECTED_LOGGING" category="PERFORMANCE" />
4 </FindbugsPlugin>
5
对于每一个新的检测器,在 FindBugs.xml 文件中增加一个 Detector 元素和一个 BugPattern 元素。 Detector 元素指定用于实现检测器的类以及它是快速还是慢速检测器。在 UI 中查看检测器时就会用到 speed 属性,如图 1 所示。speed 属性的可能值有 slow、moderate 和 fast。
图 1. 配置检测器 UI
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 的内容
2 <Detector class="cbg.FindBugs.FindUnprotectedLogging">
3 <Details>
4 <![CDATA[
5 <p> This detector finds logs statements that aren't contained in an if-logging block.
6 It is a fast detector.
7 ]]>
8 </Details>
9 </Detector>
10 <BugPattern type="CBG_UNPROTECTED_LOGGING">
11 <ShortDescription>Found unprotected logging</ShortDescription>
12 <LongDescription>Found unprotected logging in {1}</LongDescription>
13 <Details>
14 <![CDATA[
15 <p> This method logs without first checking that logging is enabled; for example
16 ... more text omitted...
17 ]]>
18 </Details>
19 </BugPattern>
20
21 <BugCode abbrev="CBGL">Found unprotected logging</BugCode>
22 </MessageCollection>
23
messages.xml 文件由三个元素组成: Detector 、 BugPattern 和 BugCode 。
检测器的 class 属性应当指定检测器的类名。 Details 元素包含检测器的简单 HTML 描述,因而应当包含在 CDATA 部分中。UI 使用这些描述,如图 2 所示:
图 2. FindBugs UI 突出显示未监护日志检测器
BugPattern 元素类似于在 FindBugs.xml 中定义的 BugPattern 元素。需要 type 属性,并且它应当匹配在 FindBugs.xml 和在检测器的 Java 代码中使用的相同惟一标识符。 BugPattern 包含三个影响有关检测器的信息在 UI 中显示方式的元素: ShortDescription 、 LongDescription 和 Details ——它们的意义都是相当直观的。
在 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 还是已经熟悉它了,我鼓励您用自己的特定于应用程序的检测器进行试验。同时,我希望本文提供如何实现自定义检测器的简洁例子,并鼓励您将这些思路应用到小组的特定情况中去。