在之前的做法中(Historically),一般使用命名模式(naming pattern) 表明有些程序元素需要通过某种工具或者框架进行特殊处理。例如,在第4版之前,JUnit测试框架要求其用户通过使用字符test作为测试方法名称的开头[Beck04]。这种方法可行,但它有几个很大的缺点。首先,文字拼写错误会导致失败,并且没有任何提示。例如,假设你不小心将测试方法命名为tsetSafetyOverride而不是testSafetyOverride。Junit 3不会出错,但是它也不会执行测试,造成错误的安全感【也就是说,那个命名错误的测试方法不会执行测试,并且不会报错,让我们误以为测试成功了的假象】。
命名模式的第二个缺点是,无法确保它们只用于相应的程序元素上。例如,假设你将某个类命名为TestSafetyMechanisms ,是希望JUnit 3会自动测试它所有的方法,而不管它们叫什么名称。Junit 3还是不会出错,但也同样不会执行测试。
命名模式的第三个缺点是它们没有提供将参数值与程序元素相关联的好方法。例如,假设您希望支持仅在抛出特定异常时才成功的测试类别。异常类型本质上是测试的参数。您可以使用一些复杂的命名模式将异常类型名称编码到测试方法的名称中,但是这样的代码会很不雅观,也很脆弱(第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 {
}
Test注解类型的声明就是它自身通过Retention和Target注解进行了注解。注解类型声明中的这种注解被称作元注解(meta-annotation)。@Retention(RetentionPolicy.RUNTIME)元注解表明,Test注解应该在运行时保留。如果没有保留,测试工具就无法知道Test注解。@Target(ElementType.METHOD)元注解表明,Test注解只在方法声明中才是合法的;它不能运用到类声明、域声明或者其他程序元素上。
注意Test注解声明上方的注释:“Use only on parameterless static methods.(只用于无参的静态方法)。”如果编译器能够强制这一限制最好,但是它做不到。除非你写一个注释处理器这样做。有关此专题的更多信息,请参阅javax.annotation.processing的文档。在缺少这样的注释处理器的情况下,如果将Test注解放在实例方法的声明中,或者放在带有一个或者多个参数的方法中,测试程序仍然会编译,让测试工具在运行时来处理这个问题。
下面就是在实践中应用Test注解,称作标记注解(marker annotation),因为它没有参数,只是“标注”被注解的元素。如果程序猿平错了Test,或者将Test注解应用到程序元素而非方法声明,程序就无法编译:
// 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() { }
}
这个Sample类有7个静态方法,其中有4个被注解为测试。在这4个里面,有2个抛出了异常:m3和m7,另外两个则没有:m1和m5。但是其中一个没有抛出异常的被注解方法:m5,是一个实例方法,因此不属于注解的有效使用。总之,Sample包含4项测试:一项会通过,两项会失败,另外一项则无效。没有用Test注解进行标注的4个方法会被测试工具忽略。
Test注释对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);
}
}
测试运行工具在命令行上使用完全匹配的类名,并通过调用Method.invoke反射式地运行类中所有标注了Test的方法。isAnnotationPresent方法告知该工具要运行哪些方法。如果测试方法抛出异常,并打印失败报告,包含测试方法抛出的原始异常,该异常时使用getCause方法从InvocationTargetException中提取出来的。
如果尝试通过反射调用测试方法时抛出InvocationTargetException之外的任何异常,表明编译时没有捕捉到Test注解的无效用法。这种用法包括实例方法的注解,或者带有一个或者多个参数的方法的注解,或者不可访问的方法的注解。测试运行类中的第二个cache块捕捉到了这些Test用法错误,并打印出相应的错误消息。下面就是RunTests在Sample上运行时打印的输出:
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>。这个通配符类型无疑很绕口。它在英语中的意思是:某个扩展Exception的类的Class对象,它允许注解的用户指定任何异常(或错误)类型。这种用法是有限制的类型令牌(第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)
}
现在我们要修改一下测试运行工具来处理新的注解。这其中包括将以下代码添加到main方法中:
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);
}
}
这段代码类似于用来处理Test注解的代码,但有一处不同:这段代码提取了注解参数的值,并用它检验该测试抛出的异常是否为正确的类型。没有显示的转换,因此没有出现ClassCastException的危险。编译过的测试程序确保它的注解参数表示的是有效的异常类型,需要提醒一点:有可能注解参数在编译时是有效的,但是表示特定异常类型的类文件在运行时却不再存在。在这种希望很少出现的情况下,测试运行类会抛出TypeNotPresentException异常。
将上面的异常测试示例再深入一点,想象测试可以在抛出任何一种指定异常时都得到通过。注解机制有一种工具,使得支持这种用法变得十分容易,假设我们将ExceptionTest注解的参数类型改成Class对象的一个数组:
// 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);
}
修改测试运行工具来处理新版本的ExceptionTest是相当简单的。此代码替换原始版本:
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();
}
以下是我们的doublelyBad测试使用重复注释代替数组值注释的方式:
// 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平台所提供的预定义的注解类型(第40、27项)。还要考虑使用IDE或者静态分析工具所提供的任何注解。这种注解可以提升由这些工具所提供的诊断信息的质量。但是要注意这些注解还没有标准化,因此如果变换工具或者形成标准,就有很多工作要做了。