ITEM 39: PREFER ANNOTATIONS TO NAMING PATTERNS
从历史上看,通常使用命名模式来表示某些程序元素需要工具或框架的特殊处理。例如,在发布4之前,JUnit测试框架要求用户以字符test[Beck04]开头指定测试方法。这项技术是有效的,但是它有几个很大的缺点。首先,排版错误会导致无声的失败。例如,假设您不小心命名了一个测试方法 tsetSafetyOverride,而不是 testSafetyOverride。JUnit 3不会抱怨,但它也不会执行测试,这导致了一种错误的安全感。
命名模式的第二个缺点是,无法确保只在适当的程序元素上使用它们。例如,假设您调用了一个 TestSafetyMechanisms 类,希望JUnit 3能够自动测试它的所有方法,而不管它们的名称。同样,JUnit 3不会抱怨,但它也不会执行测试。
命名模式的第三个缺点是,它们没有提供将参数值与程序元素关联起来的好方法。例如,假设您希望支持只有在抛出特定异常时才成功的测试类别。异常类型本质上是测试的一个参数。您可以使用一些复杂的命名模式将异常类型名称编码到测试方法名称中,但是这将是丑陋而脆弱的(item 62)。编译器将无法知道是否应该为异常命名的字符串实际执行了检查。如果指定的类不存在或不存在异常,那么只有在尝试运行测试时才会发现。
注解[JLS, 9.7]很好地解决了所有这些问题,JUnit 从第4版开始采用了它们。在这一项中,我们将编写自己的玩具测试框架来展示注解是如何工作的。假设您想定义一个注解类型来指定自动运行的简单测试,如果抛出异常,这些测试将失败。下面是这种名为 Test 的注解类型的样子:
// Marker annotation type declaration
import java.lang.annotation.*;
/**
* Indicates that the annotated method is a test method. * Use only on parameterless static methods.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
测试注解类型的声明本身使用和保留目标注解。这种注解类型声明上的注解称为元注解。@Retention(RetentionPolicy.RUNTIME) 元注解指出应该在运行时保留测试注解。如果没有它,测试工具将看不到测试注解。
@Target.get(ElementType.METHOD)元注解指出,测试注解只在方法声明上合法:它不能应用于类声明、字段声明或其他程序元素。
测试注解声明之前的注解说,“仅在无参数静态方法上使用。“如果编译器能强制执行这一点就好了,但是它不能,除非您编写一个注解处理器来实现这一点。有关此主题的更多信息,请参见javax.annotation.processing 文档。没有这样的注解处理器的情况下,如果您将测试注解放在实例方法的声明上,或者放在具有一个或多个参数的方法上,测试程序仍然会编译,让测试工具在运行时处理这个问题。
下面是测试注解在实践中的样子。它被称为标记注解,因为它没有参数,只是简单地“标记”了带注解的元素。如果程序员拼写错误测试或将测试注解应用于除方法声明外的程序元素,程序将无法编译:
// Program containing marker annotations
public class Sample {
@Test public static void m1() { } // Test should pass
public static void m2() { }
@Test public static void m3() { // Test should fail
throw new RuntimeException("Boom");
}
public static void m4() { }
@Test public void m5() { } // INVALID USE: nonstatic method
public static void m6() { }
@Test public static void m7() { // Test should fail
throw new RuntimeException("Crash");
}
public static void m8() { }
}
样例类有7个静态方法,其中4个被注解标记为test。其中两个 m3 和 m7 抛出异常,而两个 m1和 m5 没有抛出异常。但是,不抛出异常的带注解的方法之一 m5 是一个实例方法,因此它不是注解的有效使用。总之,Sample 包含四个测试:一个通过,两个失败,一个无效。测试工具将忽略没有使用测试注解进行注解的四个方法。
测试注解对示例类的语义没有直接影响。它们只提供给感兴趣的程序使用的信息。更一般地说,注解不会改变带注解代码的语义,但是可以通过以下工具对其进行特殊处理:
// Program to process marker annotations
import java.lang.reflect.*;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " failed: " + exc);
} catch (Exception exc) {
System.out.println("Invalid @Test: " + m);
}
}
}
System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);
}
}
test runner 工具在命令行上接受一个完全限定的类名,并通过调用 Method.invoke 反射性地运行该类的所有带测试注解的方法。isAnnotationPresent 方法告诉工具运行哪些方法。如果测试方法抛出异常,反射工具将其封装在 InvocationTargetException 中。该工具捕获这个异常并打印一个包含测试方法抛出的原始异常的失败报告,该方法使用 getCause 方法从InvocationTargetException 中提取。
如果通过反射调用测试方法的尝试抛出除 InvocationTargetException 之外的任何异常,则表明在编译时没有捕获测试注解的无效使用。这种用法包括实例方法、具有一个或多个参数的方法或不可访问方法的注解。
测试运行器中的第二个 catch 块捕获这些测试使用错误并打印适当的错误消息。下面是运行runtest 时打印的输出:
public static void Sample.m3() failed: RuntimeException: Boom Invalid @Test: public void Sample.m5()
public static void Sample.m7() failed: RuntimeException: Crash Passed: 1, Failed: 3
现在,让我们添加对只有在抛出特定异常时才成功的测试的支持。我们需要一个新的注解类型:
// Annotation type with a parameter
import java.lang.annotation.*;
/**
* Indicates that the annotated method is a test method that * must throw the designated exception to succeed.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
这个注解的参数类型是 Class<? extends Throwable>。不可否认,这种通配符类型有点拗口。在英语中,它的意思是“某个扩展了 Throwable 的类的类对象”,并且它允许注解的用户指定任何异常(或错误)类型。这种用法是有界类型令牌的一个例子(item 33)。下面是注解在实际中的样子。注意,类常量用作注解参数的值:
// Program containing annotations with a parameter
public class Sample2 {
@ExceptionTest(ArithmeticException.class)
public static void m1() { // Test should pass
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
public static void m2() { // Should fail (wrong exception)
int[] a = new int[0];
int i = a[1];
}
@ExceptionTest(ArithmeticException.class)
public static void m3() { } // Should fail (no exception)
}
现在让我们修改测试运行器工具来处理新的注解。这样做包括添加以下代码到主方法:
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("Test %s failed: no exception%n", m);
} catch (InvocationTargetException wrappedEx) {
Throwable exc = wrappedEx.getCause();
Class<? extends Throwable> excType = m.getAnnotation(ExceptionTest.class).value();
if (excType.isInstance(exc)) {
passed++;
} else {
System.out.printf("Test %s failed: expected %s, got %s%n", m, excType.getName(), exc);
}
} catch (Exception exc) {
System.out.println("Invalid @Test: " + m);
}
}
这段代码类似于我们用来处理测试注解的代码,但是有一个例外:这段代码提取了注解参数的值,并使用它来检查测试抛出的异常是否属于正确的类型。没有显式的强制转换,因此不会有ClassCastException 异常的危险。测试程序的编译保证其注解参数代表有效的异常类型,有一个警告:如果注解参数在编译时都是有效的,但代表指定的异常类型的类文件不再存在在运行时,测试运行器将将抛出 TypeNotPresentException。
让我们的异常测试示例更进一步,如果抛出几个指定异常中的任何一个,就有可能设想一个测试通过。注解机制有一个工具,可以方便地支持这种用法。假设我们将 ExceptionTest 注解的参数类型更改为一个类对象数组:
// Annotation type with an array parameter
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Exception>[] value();
}
注解中数组参数的语法是灵活的。它针对单元素数组进行了优化。所有之前的 ExceptionTest 注解仍然对ExceptionTest的新数组参数版本有效,并且结果是单元素数组。要指定一个多元素数组,请用花括号包围元素,并用逗号分隔它们:
// Code containing an annotation with an array parameter
@ExceptionTest({
IndexOutOfBoundsException.class,
NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<>();
// The spec permits this method to throw either
// IndexOutOfBoundsException or NullPointerException
list.addAll(5, null);
}
修改测试运行器工具来处理异常测试的新版本是相当简单的。这段代码取代了原来的版本:
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("Test %s failed: no exception%n", m);
} catch (Throwable wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
Class<? extends Exception>[] excTypes = m.getAnnotation(ExceptionTest.class).value();
for (Class<? extends Exception> excType : excTypes) {
if (excType.isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed)
System.out.printf("Test %s failed: %s %n", m, exc);
}
}
从Java 8开始,还有另一种方法可以做多值注解。不使用数组参数声明注解类型,您可以使用@Repeatable 元注解声明,以表明注解可以重复应用于单个元素。这个元注解只接受一个参数,它是包含注解类型的类对象,它的唯一参数是注解类型的数组[JLS, 9.6.3]。如果我们使用ExceptionTest 注解采用这种方法,那么注解声明看起来是这样的。请注意,包含的注解类型必须使用适当的保留策略和目标进行注解,否则声明将无法编译:
// Repeatable annotation type
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
Class<? extends Exception> value();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
ExceptionTest[] value();
}
下面是我们的 doublyBad 测试如何使用重复注解代替数组值注解:
// Code containing a repeated annotation
@ExceptionTest(IndexOutOfBoundsException.class) @ExceptionTest(NullPointerException.class)
public static void doublyBad() { ... }
处理可重复注解需要谨慎。重复注解生成包含注解类型的合成注解。getAnnotationsByType 方法掩盖了这一细节,可以用于访问可重复注解类型的重复注解和非重复注解。
但是 isAnnotationPresent 明确指出,重复注解不是注解类型,而是包含注解的类型。如果一个元素具有某种类型的重复注解,并且您使用 isAnnotationPresent 方法检查该元素是否具有该类型的注解,您将发现它没有。因此,使用此方法检查注解类型是否存在,将导致程序无声地忽略重复的注解。类似地,使用此方法检查包含的注解类型将导致程序无声地忽略非重复注解。要使用 isAnnotationPresent 检测重复和非重复注解,您需要检查注解类型及其包含的注解类型。下面是我们的RunTests程序的相关部分在修改为使用 ExceptionTest 注解的可重复版本时的样子:
// Processing repeatable annotations
if (m.isAnnotationPresent(ExceptionTest.class) || m.isAnnotationPresent(ExceptionTestContainer.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("Test %s failed: no exception%n", m);
} catch (Throwable wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
ExceptionTest[] excTests = m.getAnnotationsByType(ExceptionTest.class);
for (ExceptionTest excTest : excTests) {
if (excTest.value().isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed) System.out.printf("Test %s failed: %s %n", m, exc);
}
}
添加可重复的注解是为了提高源代码的可读性,源代码逻辑上将相同注解类型的多个实例应用于给定的程序元素。如果您觉得它们增强了源代码的可读性,那么就使用它们,但是请记住,在声明和处理可重复注解时有更多的样板文件,而且处理可重复注解很容易出错。
这个项目中的测试框架只是一个玩具,但是它清楚地展示了注解相对于命名模式的优势,并且它只涉及了您可以使用注解做什么。如果您编写的工具需要程序员向源代码添加信息,请定义适当的注解类型。如果可以使用注解,则没有理由使用命名模式。
也就是说,除了 toolsmiths 之外,大多数程序员将不需要定义注解类型。但是所有程序员都应该使用 Java 提供的预定义注解类型(item 40, 27)。此外,考虑使用 IDE 或静态分析工具提供的注解。这样的注解可以提高这些工具提供的诊断信息的质量。但是,请注意,这些注解还没有标准化,所以如果要切换工具或出现标准,您可能还有一些工作要做。
网友评论