技术开发 频道

Java 理论与实践: 平衡测试,第2部分

  编写 bug 模式的第一个步骤是清楚地标识 bug 模式。在这里,bug 模式是捕获 Exception 的 catch 块,这时不存在用于 RuntimeException 的相应捕获块,并且尝试块中的任何方法调用或 throw 语句都不会抛出 Exception。要检测此 bug 模式,则需要知道 try-catch 块的位置、try 块可能抛出的内容以及在 catch 块中将捕获的内容。

  标识捕获的异常

  像上个月的操作一样,您可以通过创建 BytecodeScanningDetector 基础类(可实现 Visitor 模式)的子类启动 bug 检测器。在 BytecodeScanningDetector 中有一个 visit(Code) 方法,并且在每次发现 catch 块时,该实现都会调用 visit(CodeException)。如果重写 visit(Code),并从那里调用 super.visit(Code),则当超类 visit(Code) 返回时,它将调用用于该方法中所有 catch 块的 visit(CodeException)。清单 4 了显示实现 visit(Code) 和 visit(CodeException) 的第一步,它将积累方法中所有 catch 块的信息。每个 CodeException 都包含相应 try 块的起始和终止的字节码偏移量,这样您可以方便地确定哪一个 CodeException 对象与 try-catch 块对应。

  清单 4. 第一版 RuntimeException 捕获检测器可以收集某一方法中抛出的异常信息

1 public class RuntimeExceptionCapture extends BytecodeScanningDetector {
2   private BugReporter bugReporter;
3   private Method method;
4   private OpcodeStack stack = new OpcodeStack();
5   private List<ExceptionCaught> catchList;
6   private List<ExceptionThrown> throwList;
7
8   public void visitMethod(Method method) {
9     this.method = method;
10     super.visitMethod(method)         }
11
12   public void visitCode(Code obj) {
13     catchList = new ArrayList<ExceptionCaught>();
14     throwList = new ArrayList<ExceptionThrown>();
15     stack.resetForMethodEntry(this);
16
17     super.visitCode(obj);
18     // At this point, we've identified all the catch blocks
19     // More to come...
20   }
21
22   public void visit(CodeException obj) {
23     super.visit(obj);
24     int type = obj.getCatchType();
25     if (type == 0) return;
26     String name =
27       getConstantPool().constantToString(getConstantPool().getConstant(type));
28
29     ExceptionCaught caughtException =
30       new ExceptionCaught(name, obj.getStartPC(), obj.getEndPC(), obj.getHandlerPC());
31     catchList.add(caughtException);
32   }
33 }
34

  标识抛出的异常

  此时,您已获得了您需要的一半信息:在何处捕获哪些异常。现在必须找出哪些异常被抛出。为此,您需要重写 BytecodeScanningDetector 的 sawOpcode() 方法,并处理与方法调用和异常抛出相对应的字节码。可以根据 athrow JVM 指令抛出异常。三个 JVM 指令分别用于调用以下方法:invokestatic、invokevirtual 和 invokespecial。就像使用 visit(CodeException) 一样,在调用超类 visit(Code) 时可以调用 sawOpcode,这样,如果在 sawOpcode() 中收集信息,那么在 super.visit(Code) 返回时,您将获得您需要的、有关捕获和抛出异常的所有信息。

  清单 5 显示了 sawOpcode() 的实现,它将处理上述 JVM 指令。对于 athrow 指令,可以使用 FindBugs 的 OpcodeStack 帮助器类来了解 athrow 操作数的类型。对于方法调用指令,可以使用 Bytecode Engineering Library (BCEL) 类来提取方法声明抛出的已检查异常的类型。在任何一种情况下,都可以积累关于哪些异常在方法中的哪个字节码偏移量被抛出的信息,这样,在完成整个方法的处理后,可以将它们进行匹配。

  清单 5. 标识受访问代码中抛出异常的位置

1 public void sawOpcode(int seen) {
2   stack.mergeJumps(this);
3   try {
4       switch (seen) {
5       case ATHROW:
6           if (stack.getStackDepth() > 0) {
7               OpcodeStack.Item item = stack.getStackItem(0);
8               String signature = item.getSignature();
9               if (signature != null && signature.length() > 0) {
10                   if (signature.startsWith("L"))
11                       signature = SignatureConverter.convert(signature);
12                   else
13                       signature = signature.replace('/', '.');
14                   throwList.add(new ExceptionThrown(signature, getPC()));
15               }
16           }
17           break;
18
19       case INVOKEVIRTUAL:
20       case INVOKESPECIAL:
21       case INVOKESTATIC:
22           String className = getDottedClassConstantOperand();
23           try {
24               if (!className.startsWith("[")) {
25                   JavaClass clazz = Repository.lookupClass(className);
26                   Method[] methods = clazz.getMethods();
27                   for (Method method : methods) {
28                       if (method.getName().equals(getNameConstantOperand())
29                               && method.getSignature().equals(getSigConstantOperand())) {
30                           ExceptionTable et = method.getExceptionTable();
31                           if (et != null) {
32                               String[] names = et.getExceptionNames();
33                               for (String name : names)
34                                   throwList.add(new ExceptionThrown(name, getPC()));
35                           }
36                           break;
37                       }
38                   }
39               }
40           } catch (ClassNotFoundException e) {
41               bugReporter.reportMissingClass(e);
42           }
43           break;
44       default:
45           break;
46       }
47   } finally {
48       stack.sawOpcode(this, seen);
49   }
50 }
51

  汇总结果

  在获得所需的关于捕获和抛出异常的信息后,最后一步是汇总这些信息。在超类 visit(Code) 的调用返回后,将完全填充 throwList 和 caughtList 集合。它们包含关于方法中所有 try-catch 块的信息,所以您必须将抛出信息和捕获信息关联,以标识 bug 模式。

  清单 6 显示了用于标识 RuntimeException 捕获的逻辑。它将迭代捕获块的列表,如果发现捕获 Exception 的块,它会再次查找捕获块,该捕获块将捕获字节码同一范围的 RuntimeException。它还可以查找在字节码的相应范围中抛出 Exception 的实例。如果没有捕获 RuntimeException,也没有抛出 Exception,则存在一个潜在的 bug。

  清单 6. 合并捕获和抛出数据,以标识 RuntimeException 捕获

1 for (ExceptionCaught caughtException : catchList) {
2     Set<String> thrownSet = new HashSet<String>();
3     for (ExceptionThrown thrownException : throwList) {
4         if (thrownException.offset >= caughtException.startOffset
5                 && thrownException.offset < caughtException.endOffset) {
6             thrownSet.add(thrownException.exceptionClass);
7             if (thrownException.exceptionClass.equals(caughtException.exceptionClass))
8                 caughtException.seen = true;
9         }
10     }
11     int catchClauses = 0;
12     if (caughtException.exceptionClass.equals("java.lang.Exception")
13       && !caughtException.seen) {
14         // Now we have a case where Exception is caught, but not thrown
15         boolean rteCaught = false;
16         for (ExceptionCaught otherException : catchList) {
17             if (otherException.startOffset == caughtException.startOffset
18                     && otherException.endOffset == caughtException.endOffset) {
19                 catchClauses++;
20                 if (otherException.exceptionClass.equals("java.lang.RuntimeException"))
21                     rteCaught = true;
22             }
23         }
24         int range = caughtException.endOffset - caughtException.startOffset;
25         if (!rteCaught) {
26             bugReporter.reportBug(new BugInstance(this, "REC_CATCH_EXCEPTION",
27                     NORM_PRIORITY)
28                     .addClassAndMethod(this)
29                     .addSourceLine(this, caughtException.sourcePC));
30         }
31     }
32 }
33

  要编写 bug 检测器,则需要了解 JVM 字节码和类文件的一些结构。BCEL 和 FindBugs 库将为您处理此任务,并从字节码中提取信息,在稍高级别上呈现它。遗憾的是,关于 BCEL 和 FindBugs 如何支持类分离的文档并不能满足您的需要。像使用许多开放源码项目一样,关于如何编写检测器的非常好的信息源是参照执行类似任务的其他检测器。

  优化检测器

  使用静态分析的最大消耗是处理假警报。静态分析不一定精确,其目标不是发现 bug,而是只发现那些可能是 bug 的构造,这意味着有时会标记正确的代码出错。如果代码审核工具产生 95% 的假警报,那么任何人都不太可能想再次使用它;第一次发现报告新 bug 的假警报真的很痛苦。所以对于一个有效的 bug 模式检测器,它必须最小化假警报数量,最好使假情报不超过 50%。

  优化检测器的非常好的方法是在 JDK 类库 (rt.jar)、Eclipse 或 JBoss 之类的大型代码基址上运行它。所以在编写 bug 检测器后,应该试着在新的项目和示例上运行它,以查看它们是真实的 bug,还是假警报。对于非凡的检测器(比如这里开发的检测器),第一次体验常常有点让人失望—— 假警报比预期的多。

  优化检测器的过程包括查找假警报和细化 bug 模式,以消除某些假警报,同时不要将太多的真实 bug 排除在外。为细化模式,可以执行的操作之一是消除以下情况:存在零或 try 块中抛出已经过检查的异常;在这些情况下,捕获 Exception 不可能导致尝试合并多个捕获块,而会导致反映对捕获未经检查的异常的真实愿望。此修改对假警报率有很大的影响。

  优化检测器通常包括 “得分” 算法的使用,以确定是否将匹配报告为 bug。通过使用几个因素可执行其他调优,以增加或减少对给定实例的 “信心得分”。某些方面(如不存在任何已检查的异常)可以减少候选匹配的得分;其他方面,比如捕获异常失效方面(在捕获块中不使用),可以增加候选匹配的得分。清单 7 显示了对此检测器进行优化后形成的得分算法;它将优先级用作得分,因为在某一阈值上具有优先权的 bug 被 bug 报告的框架忽略(较高优先级的值指示的实际 bug 的严重性较低)。

  清单 7. 优化后 RuntimeException 捕获检测器使用的得分算法

1 if (!rteCaught) {
2     int priority = LOW_PRIORITY + 1;
3     if (range > 300) priority--;
4     else if (range < 30) priority++;
5     if (catchClauses > 1) priority++;
6     if (thrownSet.size() > 1) priority--;
7     if (caughtException.dead) priority--;
8     bugReporter.reportBug(new BugInstance(this, "REC_CATCH_EXCEPTION",
9             priority)
10             .addClassAndMethod(this)
11             .addSourceLine(this, caughtException.sourcePC));
12 }
13

  结束语

  为静态代码分析工具(如 FindBugs)编写自定义 bug 检测器可以显著提高代码质量,并且有许多乐趣。尽管编写和优化 bug 检测器非常困难(优化它们对确保其能够使用非常重要),用检测器捕获 bug 模式的信息要付出高昂的代价,但是,使用这些信息能够花费少量工作来扫描任何项目中的 bug 模式,从而使您对最愚蠢的 bug 查找方式感到惊讶。

0
相关文章