vlambda博客
学习文章列表

JAVA编程思想(4)-11-注解

1. 注解

注解,也被称为元数据,为我们在代码中添加信息提供了一种形式化的方法,使我们可以在稍后某个时刻非常方便地使用这些数据。注解在Java SE5中引入,在一定程度是在把元数据与源代码文件结合在一起,而不是保存在外部文档中这一大趋势之下所催生的,可以联想到Spring早期的xml配置文件。


注解是真正的语言级的概念,一旦构造出来,就享有编译期的类型检查保护。注解是在实际的源代码级别保存所有的信息,而不是某种注释性的文字。通过使用扩展的annotation API,或外部的字节码工具类库,我们将拥有对源代码以及字节码强大的检查与操作能力。


注解的优点:

1.注解使我们能够用将会被编译器测试和验证的格式,来存储有关程序的额外信息2.注解可以用来生成描述符文件,甚至是新的类定义,并且有助于减轻编写“样板”代码的负担。3.通过使用注解,我们可以将这些元数据保存在Java源代码中,并利用annotation API为自己的注解构造处理工具4.注解还能带来更加干净易读的代码,以及编译期的类型检查


注解的语法比较简单,除了@符号的使用之外,它基本上与Java固有的语法一致。Java SE5中内置了前三种标准注解,定义在java.lang中:

1.@Override: 表示当前的方法定义将覆盖超类中的方法。这个注解的使用是可选的,并且方法签名会受到编译期检查。2.@Deprecated: 表示被修饰的元素将在未来被废弃,如果这个元素被使用,编译器会发出警告信息。3.@SuppressWarnings: 关闭不当的编译器警告信息。4.@SafeVarargs:在 Java 7 中加入用于禁止对具有泛型varargs参数的方法或构造函数的调用方发出警告。5.@FunctionalInterface:Java 8 中加入用于表示类型声明为函数式接口。

1.1. 基本语法

1.1.1. 定义注解

首先来定义一个@Test:


package atunit;
import java.lang.annotation.*;
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface Test { }


现在来使用这个@Test:


package annotations;
import atunit.Test;
public class Testable { public void execute() { System.out.println("Executing.."); }
@Test void testExecute() { execute(); }}


可以看到,注解的定义看起来很像接口的定义,并且注解也会被编译成class文件。定义注解时,会需要一些元注解(meta-annotation),例如@Target和@Retention。@Target用来定义你的注解将应用于什么地方,如方法上、域上;而@Retention用来定义该注解在哪一级别可用,如源代码中、类文件中、运行时。


在注解中,一般都会包含一些元素以表示某些值,这些元素看起来就像接口的方法,不过可以指定默认值。在分析处理注解时,程序或工具可以利用这些值。而没有元素的注解被称为标记注解,就像这个示例中的@Test。

下面再来定义一个注解,可以用它来跟踪一个项目中的用例:


package annotations;
import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface UseCase { public int id(); public String description() default "no description";}


在下面的类中,有三个方法被注解为用例:


package annotations;
import java.util.List;
public class PasswordUtils {
@UseCase(id=47, description = "...") public boolean validatePassword(String password) { return password.matches("\\w*\\d\\w*"); }
@UseCase(id=48) public String encryptPassword(String password) { return new StringBuilder(password).reverse().toString(); }
@UseCase(id=49, description = "...") public boolean checkForNewPassword( List<String> prevPasswords, String password) { return !prevPasswords.contains(password); }}


注解的元素在使用时表现为名-值对的形式,并且需要置于@UseCase声明之后的括号内。

1.1.2. 元注解

Java中除了前面提到了三种标准注解,还提供了四种元注解,专职负责注解其他的注解。

注解名 说明 可选参数
@Target 表示该注解可以用于什么地方 CONSTRUCTOR:构造器、FIELD:域(包括enum实例)、 LOCAL_VARIABLE:局部变量、METHOD:方法、PACKAGE:包、PARAMETER:参数、TYPE:类、接口(包括注解类型)或enum
@Retention 表示需要在什么级别保存该注解 SOURCE:注解将被编译器丢弃、CLASS:注解在class文件中可用,但会被VM丢弃、RUNTIME:VM将在运行期也保留注解,因此可以通过反射机制读取注解的信息
@Documented 将此注解包含在Javadoc中
@Inherited 允许子类继承父类中的注解

1.2. 编写注解处理器

如果没有用来读取注解的工具,注解也不会比注释更有用。Java SE5扩展了反射机制的API来便于构造注解处理器,同时还提供了一个外部工具apt来助于解析带有注解的Java源代码,不过在Java 1.8中,apt被移除了,取而代之的是javac -processor。


下面是一个非常简单的注解处理器,我们将用它来读取PasswordUtils类,并用反射机制查找@UseCase标记。我们为其提供了一组id值,然后它会列出在PasswordUtils中找到的用例,以及缺失的用例:


package annotations;
import java.lang.reflect.Method;import java.util.ArrayList;import java.util.Collections;import java.util.List;
public class UseCaseTracker { public static void trackUseCases(List<Integer> useCases, Class<?> clazz) { for (Method method : clazz.getDeclaredMethods()) { UseCase useCase = method.getAnnotation(UseCase.class); if (useCase != null) { System.out.println("Found Use Case: " + useCase.id()); useCases.remove(new Integer(useCase.id())); } }
for (int i : useCases) { System.out.println("Warning: Missing use case: " + i); } }
public static void main(String[] args) { List<Integer> useCaseList = new ArrayList<>(); Collections.addAll(useCaseList, 47, 48, 49, 50); trackUseCases(useCaseList, PasswordUtils.class); }
}/* Output:Found Use Case: 47Found Use Case: 48Found Use Case: 49Warning: Missing use case: 50*/

1.2.1. 注解元素

注解元素可用的类型有:

1.所有基本类型(int, float, boolean等)2.String3.Class4.enum5.Annotation6.以上类型的数组

注意,注解元素不允许使用包装类型,不过有自动包装机制的存在,这就算不上什么限制了。

1.2.2. 默认值限制

编译器对元素的默认值有严格的限制:

1.元素不能有不确定的值,要么具有默认值,要么在使用注解时提供元素的值。2.对于非基本类型的元素,无论是在源代码中声明时,还是注解接口中定义默认值时,都不能以null作为其值。

第二个约束使处理器很难表现一个元素存在或缺失的状态,因为在每个注解的声明中,所有的元素都存在,并且都具有相应的值。为了绕开这个约束,我们可以定义空字符串或者负数来表示某个元素不存在。

1.2.3. 生成外部文件

有些framework需要一些额外信息才能与你的源代码协同工作,这种情况最适合注解了。


假设现在需要提供一些基本的对象/关系映射功能,能够自动生成数据库表,用以存储JavaBean对象。如果使用注解的话,可以将所有信息保存在JavaBean源文件中,为此我们需要定义一些新注解来定义与bean关联的数据库表的名字,以及与Bean属性关联的列的名字和SQL类型。


下面是一个注解的定义,它告诉注解处理器,你需要为我生成一个数据库表:


package annotations;
import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;
@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)public @interface DBTable { // 表的名字 public String name() default "";}


在@Target注解中指定的每个ElementType就是一个约束,如果想要指定多个值需要用逗号隔开,而如果想将注解应用于所有的ElementType,可以省略@Target元注解。

下面是修饰JavaBean域的三个注解:


// annotations/Constraints.javapackage annotations;
import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;
@Target(ElementType.FIELD)@Retention(RetentionPolicy.RUNTIME)public @interface Constraints { boolean primaryKey() default false; boolean allowNull() default true; boolean unique() default false;}
// annotations/SQLString.javapackage annotations;
import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;
@Target(ElementType.FIELD)@Retention(RetentionPolicy.RUNTIME)public @interface SQLString { int value() default 0;
String name() default "";
Constraints constraints() default @Constraints;}
// annotations/SQLInteger.javapackage annotations;
import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;
@Target(ElementType.FIELD)@Retention(RetentionPolicy.RUNTIME)public @interface SQLInteger { String name() default "";
Constraints constraints() default @Constraints;}


注解处理器通过@Constraints注解提取出数据库的元数据,后两个定义了两种SQL类型作为示例。如果想要让嵌入的@Constraints注解中的unique()元素为true,并以此作为constraints()元素的默认值,就需要像这样:Constraints constraint() default @Constraints(unique = true);

然后是一个简单的Bean定义,我们在其中应用了上面的注解:


package annotations;
@DBTable(name = "MEMBER")public class Member { @SQLString(30) String firstName; @SQLString(50) String lastName; @SQLInteger Integer age; @SQLString(value = 30, constraints = @Constraints(primaryKey = true)) String handle;
static int memberCount;
}


在注解使用中,有一个快捷方式:如果在注解中定义了名为value的元素,并且在应用该注解时,value是唯一需要赋值的元素,就可以直接在括号中给出元素所需要的值。这种使用方式要求元素只能命名为value。

1.2.4. 注解不支持继承

不能使用关键字extends来继承某个注解。至少目前,Java语法上没有实现注解的继承机制。

1.2.5. 实现处理器

下面是一个注解处理器的例子,它将读取一个类文件,检查其上的数据注解,并生成用来创建数据库的SQL命令。


package annotations;
import java.lang.annotation.Annotation;import java.lang.reflect.Field;import java.util.ArrayList;import java.util.List;
public class TableCreator { public static void main(String[] args) throws Exception { String className = "annotations.Member"; Class<?> clazz = Class.forName(className); DBTable dbTable = clazz.getAnnotation(DBTable.class); if (dbTable == null) { System.out.println("No DBTable annotations in class " + className); }
String tableName = dbTable.name(); if (tableName.length() < 1) { tableName = clazz.getName().toUpperCase(); }
// 保存每一列的SQL语句 List<String> columnDefs = new ArrayList<>(); for (Field field : clazz.getDeclaredFields()) { String columnName = null; Annotation[] annotations = field.getDeclaredAnnotations(); // 这个字段上没有注解 if (annotations.length < 1) { continue; }
// 在这个示例中,字段上只有一个注解 if (annotations[0] instanceof SQLInteger) { SQLInteger sqlInteger = (SQLInteger) annotations[0]; // 如果name元素没有指定,就使用字段名 if (sqlInteger.name().length() < 1) { columnName = field.getName().toUpperCase(); } else { className = sqlInteger.name(); }
columnDefs.add(columnName + " INT" + getConstraints(sqlInteger.constraints())); }
if (annotations[0] instanceof SQLString) { SQLString sqlString = (SQLString) annotations[0]; if (sqlString.name().length() < 1) { columnName = field.getName().toUpperCase(); } else { columnName = sqlString.name(); }
columnDefs.add(columnName + " VARCHAR(" + sqlString.value() + ")" + getConstraints(sqlString.constraints())); } }
StringBuilder stringBuilder = new StringBuilder("CREATE TABLE " + tableName + "("); for (String columnDef : columnDefs) { stringBuilder.append("\n ").append(columnDef).append(","); }
// 去掉最后一个语句跟着的逗号 String tableCreated = stringBuilder.substring(0, stringBuilder.length() - 1) + ");"; System.out.println("Table Creation SQL for " + className + " is :\n" + tableCreated);
}
private static String getConstraints(Constraints cons) { String constraints = ""; if (!cons.allowNull()) { constraints += " NOT NULL"; }
if (cons.primaryKey()) { constraints += " PRIMARY KEY"; }
if (cons.unique()) { constraints += " UNIQUE"; }
return constraints;
}}/* Output:Table Creation SQL for annotations.Member is :CREATE TABLE MEMBER( FIRSTNAME VARCHAR(30), LASTNAME VARCHAR(50), AGE INT, HANDLE VARCHAR(30) PRIMARY KEY);*/

1.3. 使用javac处理注解

在Java SE5中,注解处理工具apt是为了帮助注解的处理过程而提供的工具,不过apt在Java8中被移除了。


不过我们可以通过javac来创建编译时(compile-time)注解处理器在 Java 源文件上使用注解,而不是编译之后的 class 文件。但是这里有一个重大限制:你不能通过处理器来改变源代码。唯一影响输出的方式就是创建新的文件。


如果你的注解处理器创建了新的源文件,在新一轮处理中注解会检查源文件本身。工具在检测一轮之后持续循环,直到不再有新的源文件产生。然后它会编译所有的源文件。


每一个你编写的注解都需要处理器,但是javac可以非常容易的将多个注解处理器合并在一起。你可以指定多个需要处理的类,并且你可以添加监听器用于监听注解处理完成后接到通知。

1.3.1. 最简单的处理器

我们来定义一个最简单的处理器,只是为了编译与测试用。首先是注解的定义:


package annotations;
import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;
@Retention(RetentionPolicy.SOURCE)@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.ANNOTATION_TYPE, ElementType.PACKAGE, ElementType.FIELD, ElementType.LOCAL_VARIABLE})public @interface Simple { String value() default "-default-";}


@Retention的参数现在为SOURCE,这意味着注解不会存留在编译后的代码。这在编译时处理注解是没有必要的,它只是指出,在这里,javac是唯一有机会处理注解的代理。@Target声明了几乎所有的目标类型(除了PACKAGE),同样是为了演示。

现在来使用这个注解:


package annotations;
@Simplepublic class SimpleTest {
@Simple private int i;
@Simple public SimpleTest() { }
@Simple public void foo() { System.out.println("SimpleTest.foo()"); }
@Simple public void bar(String s, int i, float f) { System.out.println("SimpleTest.bar()"); }

@Simple public static void main(String[] args) { @Simple SimpleTest simpleTest = new SimpleTest(); simpleTest.foo(); simpleTest.bar("", 0, 0f); }}


在这里我们使用@Simple注解了所有@Target声明允许的地方。SimpleTest.java只需要Simple.java就可以编译成功。

不过当我们编译的时候什么都没有发生,因为javac允许@Simple注解在我们创建处理器并将其hook到编译器之前,不做任何事情。

如下是一个十分简单的处理器,它的功能就是把注解相关的信息打印出来:


package annotations;
import javax.annotation.processing.*;import javax.lang.model.SourceVersion;import javax.lang.model.element.*;import java.util.Set;
@SupportedAnnotationTypes("annotations.Simple")@SupportedSourceVersion(SourceVersion.RELEASE_8)public class SimpleProcessor extends AbstractProcessor {
@Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { // 这里只有一个注解 for (TypeElement typeElement : annotations) { System.out.println(typeElement); }
for (Element element : roundEnv.getElementsAnnotatedWith(Simple.class)) { display(element); }
return false; }
private void display(Element element) { System.out.println("====== " + element + " ======"); System.out.println(element.getKind() + ":" + element.getModifiers() + ":" + element.getSimpleName() + ":" + element.asType());
if(element.getKind().equals(ElementKind.CLASS)){ TypeElement typeElement = (TypeElement)element; System.out.println(typeElement.getQualifiedName()); System.out.println(typeElement.getSuperclass()); System.out.println(typeElement.getEnclosedElements()); }
if(element.getKind().equals(ElementKind.METHOD)){ ExecutableElement executableElement = (ExecutableElement)element; System.out.println(executableElement.getReturnType()); System.out.println(executableElement.getSimpleName()); System.out.println(executableElement.getParameters()); } }

}


(旧的,失效的)apt 版本的处理器需要额外的方法来确定支持哪些注解以及支持的Java版本。不过,你现在可以简单的使用 @SupportedAnnotationTypes 和 @SupportedSourceVersion 注解。


你唯一需要实现的方法就是process(),这里是所有行为发生的地方。第一个参数告诉你哪个注解是存在的,第二个参数保留了剩余信息。通过使用process()的第二个参数,我们循环所有被@Simple 解的元素,并且针对每一个元素调用我们的display()方法。所有Element展示了自身的基本信息。例如,getModifiers()告诉你它是否为public和static。


Element只能执行那些编译器解析的所有基本对象共有的操作,而类和方法有额外的信息需要提取。所以需要检查它是哪种 ElementKind,然后将其向下转换为更具体的元素类型,转型成针对CLASS的TypeElement和针对METHOD的ExecutableElement,就可以为这些元素调用具体的方法了。


如果只是通过平常的方式来编译 SimpleTest.java,你不会得到任何结果。为了得到注解输出,你必须增加一个processor标志并且连接注解处理器类。首先编译Simple.java与SimpleProcessor.java,然后在annotations上层的目录执行:

javac -processor annotations.SimpleProcessor annotations/Simpl-eTest.java


现在就有了编译的输出:

annotations.Simple====== annotations.SimpleTest ======CLASS:[public]:SimpleTest:annotations.SimpleTestannotations.SimpleTestjava.lang.Objecti,SimpleTest(),foo(),bar(java.lang.String,int,float),main(java.lang.String[])====== i ======FIELD:[private]:i:int====== SimpleTest() ======CONSTRUCTOR:[public]:<init>:()void====== foo() ======METHOD:[public]:foo:()voidvoidfoo
====== bar(java.lang.String,int,float) ======METHOD:[public]:bar:(java.lang.String,int,float)voidvoidbars,i,f====== main(java.lang.String[]) ======METHOD:[public, static]:main:(java.lang.String[])voidvoidmainargs

1.3.2. 更复杂的处理器

当你创建用于javac的注解处理器时,你不能使用Java的反射特性,因为你处理的是源代码,而并非是编译后的class文件。各种 mirror解决这个问题的方法是,通过允许你在未编译的源代码中查看方法、字段和类型。


如下是一个用于提取类中方法的注解,所以它可以被抽取成为一个接口:


package annotations;
import java.lang.annotation.*;
@Target(ElementType.TYPE)@Retention(RetentionPolicy.SOURCE)public @interface ExtractInterface { String interfaceName() default "-!!-";}


接下来的测试类提供了一些公用方法,这些方法可以成为接口的一部分:


package annotations;@ExtractInterface(interfaceName = "IMultiplier")public class Multiplier { public boolean flag = false; private int n = 0;

public int multiplier(int x, int y) { int total = 0; for (int i = 0; i < x; i++) { total = add(total, y); }
return total; }
public int fortySeven() { return 47; }
private int add(int x, int y) { return x + y; }
public double timesTen(double arg) { return arg * 10; }
public static void main(String[] args) { Multiplier multiplier = new Multiplier(); System.out.println("11 * 16 = " + multiplier.multiplier(11, 16)); }}


这里有一个编译时处理器用于提取方法,并创建一个新的interface源代码文件,这个源文件将会在下一轮中被自动编译:


package annotations;
import javax.annotation.processing.*;import javax.lang.model.SourceVersion;import javax.lang.model.element.*;import javax.lang.model.util.*;import java.util.*;import java.util.stream.*;import java.io.*;@SupportedAnnotationTypes("annotations.ExtractInterface")@SupportedSourceVersion(SourceVersion.RELEASE_8)public class IfaceExtractorProcessor extends AbstractProcessor { private ArrayList<Element> interfaceMethods = new ArrayList<>(); private Elements elementUtils; private ProcessingEnvironment processingEnv; @Override public void init( ProcessingEnvironment processingEnv) { this.processingEnv = processingEnv; elementUtils = processingEnv.getElementUtils(); } @Override public boolean process( Set<? extends TypeElement> annotations, RoundEnvironment env) { for(Element elem:env.getElementsAnnotatedWith( ExtractInterface.class)) { String interfaceName = elem.getAnnotation( ExtractInterface.class).interfaceName(); for(Element enclosed : elem.getEnclosedElements()) { if(enclosed.getKind() .equals(ElementKind.METHOD) && enclosed.getModifiers() .contains(Modifier.PUBLIC) && !enclosed.getModifiers() .contains(Modifier.STATIC)) { interfaceMethods.add(enclosed); } } if(interfaceMethods.size() > 0) writeInterfaceFile(interfaceName); } return false; } private void writeInterfaceFile(String interfaceName) { try(Writer writer = processingEnv.getFiler() .createSourceFile(interfaceName) .openWriter()) { String packageName = elementUtils .getPackageOf(interfaceMethods .get(0)).toString(); writer.write( "package " + packageName + ";\n"); writer.write("public interface " + interfaceName + " {\n"); for(Element elem : interfaceMethods) { ExecutableElement method = (ExecutableElement)elem; String signature = " public "; signature += method.getReturnType() + " "; signature += method.getSimpleName(); signature += createArgList( method.getParameters()); System.out.println(signature); writer.write(signature + ";\n"); } writer.write("}"); } catch(Exception e) { throw new RuntimeException(e); } } private String createArgList( List<? extends VariableElement> parameters) { String args = parameters.stream() .map(p -> p.asType() + " " + p.getSimpleName()) .collect(Collectors.joining(", ")); return "(" + args + ")"; }}


Filer是PrintWriter的一种实例,可以用于创建新文件。我们使用Filer对象,而不是原生的PrintWriter原因是,这个对象可以运行javac追踪你创建的新文件,这使得它可以在新一轮中检查新文件中的注解并编译文件。


首先编译注解类与注解处理器类:javac IfaceExtractorProcessor.java ExtractInterface.java

然后在上级目录执行命令:javac -processor annotations.IfaceExtractorProcessor annotations/Multiplier.java

输出如下:


 public int multiplier (int x, int y) public int fortySeven () public double timesTen (double arg)


javac会在执行命令的目录生成的IMultiplier.java的文件,这个类同样会被javac编译(在某一轮中),所以你会在同一个目录中看到 IMultiplier.class 文件。如下所示:


package annotations;public interface IMultiplier { public int multiplier (int x, int y); public int fortySeven (); public double timesTen (double arg);}

1.4. 基于注解的单元测试

单元测试是对类中每个方法提供一个或者多个测试的一种事件,其目的是为了有规律的测试一个类中每个部分是否具备正确的行为。在 Java中,最著名的单元测试工具就是JUnit,在JUnit4版本中已经包含了注解。


通过注解,我们可以将单元测试集成在需要被测试的类中,从而将单元测试的时间和麻烦降到了最低。这种方式有额外的好处,就是使得测试私有方法和公有方法变的一样容易。


在这里我们将要自定义一个基于注解的测试框架叫做@Unit。我们用最开始定义的@Test来标记测试方法,测试方法不带参数,并返回boolean结果来说明测试方法成功或者失败,并且测试方法可以是private的。

1.4.1. 实现@Unit

首先我们需要定义所有的注解类型。这些都是简单的标签,并且没有任何字段。@Test标签在本章开头已经定义过了,这里是其他所需要的注解:


// TestObjectCleanup.javapackage atunit;
import java.lang.annotation.*;
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface TestObjectCleanup {}
// TestObjectCreate.javapackage atunit;
import java.lang.annotation.*;
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface TestObjectCreate {}
// TestProperty.javapackage atunit;
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.FIELD})@Retention(RetentionPolicy.RUNTIME)public @interface TestProperty {}


所有测试的保留属性都为 RUNTIME,这是因为 @Unit 必须在编译后的代码中发现这些注解。

要实现系统并运行测试,我们还需要反射机制来提取注解。下面这个程序通过注解中的信息,决定如何构造测试对象,并在测试对象上运行测试。


package atunit;
import java.io.File;import java.io.IOException;import java.lang.reflect.InvocationTargetException;import java.lang.reflect.Method;import java.lang.reflect.Modifier;import java.nio.file.Files;import java.util.ArrayList;import java.util.List;
public class AtUnit implements ProcessFiles.Strategy { private static Class<?> testClass; private static List<String> failedTests = new ArrayList<>(); private static long testsRun = 0; private static long failures = 0;
public static void main(String[] args) { // 开启断言 ClassLoader.getSystemClassLoader().setDefaultAssertionStatus(true); // 执行测试 new ProcessFiles(new AtUnit(), "class").start(args);
if (failures == 0) { System.out.println("OK(" + testsRun + "tests)"); } else { System.out.println("(" + testsRun + "tests)"); System.out.println("\n>>>" + failures + " FAILURE" + (failures > 1 ? "S" : "") + "<<<"); for (String failed : failedTests) { System.out.println(" " + failed); } } }
@Override public void process(File file) { try { String cName = ClassNameFinder.thisClass(Files.readAllBytes(file.toPath()));
if (!cName.startsWith("public:")) return; // 去掉访问权限修饰符 cName = cName.split(":")[1]; // 忽略不在包下的类 if (!cName.contains(".")) return;
testClass = Class.forName(cName); } catch (IOException | ClassNotFoundException e) { throw new RuntimeException(e); }
TestMethods testMethods = new TestMethods(); Method creator = null; Method cleanup = null; for (Method method : testClass.getDeclaredMethods()) { testMethods.addIfTestMethod(method); if (creator == null) { creator = checkForCreatorMethod(method); }
if (cleanup == null) { cleanup = checkForCleanupMethod(method); } }
if (testMethods.size() > 0) { if (creator == null) { try { if (!Modifier.isPublic(testClass.getDeclaredConstructor(null).getModifiers())) { System.out.println("Error: " + testClass + "no-arg constructor must be public"); System.exit(1); } } catch (NoSuchMethodException e) {
} }
System.out.println(testClass.getName()); }
for (Method method : testMethods) { System.out.print(" . " + method.getName() + " "); try { Object testObject = createTestObject(creator); boolean success = false; try { if (method.getReturnType().equals(boolean.class)) { success = (Boolean) method.invoke(testObject); } else { // 如果没有断言失败 method.invoke(testObject); success = true; } } catch (InvocationTargetException e) { System.out.println(e.getCause()); }
System.out.println(success ? "" : "(failed)"); testsRun++; if (!success) { failures++; failedTests.add(testClass.getName() + ": " + method.getName()); }
if (cleanup != null) { cleanup.invoke(testObject, testObject); } } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { throw new RuntimeException(e); } } }
public static class TestMethods extends ArrayList<Method> { void addIfTestMethod(Method method) { if (method.getAnnotation(Test.class) == null) return; if (!(method.getReturnType().equals(boolean.class) | method.getReturnType().equals(void.class))) { throw new RuntimeException("@Test method must return boolean or void"); }
// 将私有的方法设置为public method.setAccessible(true); add(method);
} }
private static Method checkForCreatorMethod(Method method) { if (method.getAnnotation(TestObjectCreate.class) == null) return null; if (!method.getReturnType().equals(testClass)) throw new RuntimeException("@TestObjectCreate must return instance of Class to be tested");
if ((method.getModifiers() & Modifier.STATIC) < 1) throw new RuntimeException("@TestObjectCreate must be static.");
method.setAccessible(true); return method; }
private static Method checkForCleanupMethod(Method method) { if (method.getAnnotation(TestObjectCleanup.class) == null) return null; if (!method.getReturnType().equals(void.class)) throw new RuntimeException("@TestObjectCleanup must return void");
if ((method.getModifiers() & Modifier.STATIC) < 1) throw new RuntimeException("@TestObjectCleanup must be static."); if (method.getParameterTypes().length == 0 || method.getParameterTypes()[0] != testClass) throw new RuntimeException("@TestObjectCleanup must take an argument of the tested type.");
method.setAccessible(true); return method; }
private static Object createTestObject(Method creator) { if (creator != null) { try { return creator.invoke(testClass); } catch (IllegalArgumentException | IllegalAccessException | InvocationTargetException e) { throw new RuntimeException("Couldn't run TestObject (creator) method."); } // 使用无参构造器 } else { try { return testClass.newInstance(); } catch (InstantiationException | IllegalAccessException e) { throw new RuntimeException("Couldn't create a test object. Try using a @TestObject method."); } }
}}


AtUnit.java使用了ProcessFiles工具进行逐步判断命令行中的参数,来决定它是一个目录还是文件,并采取相应的行为。这可以应用于不同的解决方法,是因为它包含了一个可用于自定义的Strategy接口:


package atunit;
import java.io.File;import java.io.IOException;import java.nio.file.FileSystems;import java.nio.file.Files;import java.nio.file.PathMatcher;
public class ProcessFiles {
public interface Strategy { void process(File file); }
private Strategy strategy; private String ext;
public ProcessFiles(Strategy strategy, String ext) { this.strategy = strategy; this.ext = ext; }
public void start(String[] args) { try { if (args.length == 0) { processDirectoryTree(new File(".")); } else { for (String arg : args) { File fileArg = new File(arg); if (fileArg.isDirectory()) { processDirectoryTree(fileArg); } else { if (!arg.endsWith("." + ext)) { arg += "." + ext; }
strategy.process(new File(arg).getCanonicalFile()); }
} } } catch (IOException e) { throw new RuntimeException(e); } }
private void processDirectoryTree(File file) throws IOException { PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:**/*.{" + ext + "}"); Files.walk(file.toPath()) .filter(matcher::matches) .forEach(p -> strategy.process(p.toFile())); }
public static void main(String[] args) { new ProcessFiles(file -> System.out.println(file), "java").start(args); }}


AtUnit类实现了ProcessFiles.Strategy,其包含了一个process()方法。在这种方式下,AtUnit实例可以作为参数传递给 ProcessFiles构造器。第二个构造器的参数告诉ProcessFiles寻找所有包含特定拓展名的文件,这里用的是“.class”。


与@JUnit相比,因为@Unit会自动找到可测试的类和方法,所以不需要“套件”(suites)机制。而在@JUnit中,必须告诉测试工具你打算测试什么,这就要求用套件来组织测试,以便JUnit能够找到它们,并运行其中包含的测试。


AtUnit.java中存在的一个我们必须要解决的问题是,当它发现类文件时,类文件名中的限定类名(包括包)不明显。为了发现这个信息,必须解析类文件。找到.class文件时,会打开它并读取其二进制数据并将其传递给ClassNameFinder.thisClass()。在这里,我们会涉及到字节码相关内容:


package atunit;
import java.io.ByteArrayInputStream;import java.io.DataInputStream;import java.io.IOException;import java.nio.file.FileSystems;import java.nio.file.Files;import java.nio.file.PathMatcher;import java.nio.file.Paths;import java.util.HashMap;import java.util.Map;
public class ClassNameFinder { public static String thisClass(byte[] classBytes) { Map<Integer, Integer> offsetTable = new HashMap<>(); Map<Integer, String> classNameTable = new HashMap<>();
try { DataInputStream data = new DataInputStream(new ByteArrayInputStream(classBytes)); // 字节码开头的魔术值:0xcafebabe int magic = data.readInt(); // 次版本号 int minorVersion = data.readShort(); // 主版本号 int majorVersion = data.readShort(); // 常量池大小 int constantPoolCount = data.readShort();
int[] constantPool = new int[constantPoolCount]; for (int i = 1; i < constantPoolCount; i++) { int tag = data.read(); switch (tag) { case 1: // UTF // 获取到className int length = data.readShort(); char[] bytes = new char[length]; for (int k = 0; k < length; k++) { bytes[k] = (char) data.read(); }
String className = new String(bytes); classNameTable.put(i, className); break; case 5: // LONG case 6: // DOUBLE // 丢掉8个字节,进行跳过 data.readLong(); i++; break; case 7: // CLASS // 找到类的坐标 int offset = data.readShort(); offsetTable.put(i, offset); break; case 8: // STRING // 丢掉两个字节 data.readShort(); break; case 3: // INTEGER case 4: // FLOAT case 9: // FIELD_REF case 10: // METHOD_REF case 11: // INTERFACE_METHOD_REF case 12: // NAME_AND_TYPE case 18: // Invoke Dynamic // 丢掉四个字节 data.readInt(); break; case 15: // Method Handle data.readByte(); data.readShort(); break; case 16: // Method Type data.readShort(); break; default: throw new RuntimeException("Bad tag " + tag);
} }
short accessFlags = data.readShort(); String access = (accessFlags & 0x0001) == 0 ? "nonpublic:" : "public:"; int thisClass = data.readShort(); int superClass = data.readShort(); return access + classNameTable.get(offsetTable.get(thisClass)).replace('/', '.'); } catch (IOException | RuntimeException e) { throw new RuntimeException(e); } }
public static void main(String[] args)throws Exception { PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:**/*.class"); Files.walk(Paths.get(".")) .filter(matcher::matches) .map(p -> { try { return thisClass(Files.readAllBytes(p)); } catch (Exception e) { throw new RuntimeException(e); } }).filter(s -> s.startsWith("public:")) .map(s -> s.split(":")[1]) .filter(s -> !s.startsWith("enums.")) .filter(s -> s.contains(".")) .forEach(System.out::println); }}


在字节码内容中的常量池里,每一个元素,其长度可能是固定式也可能是可变的值,因此我们必须检查每一个常量的起始标记,然后才能知道该怎么做,这就是 switch 语句的工作。


因为我们并不打算精确的分析类中所有的数据,所以仅仅是从文件的起始一步一步的走,直到取得我们所需的信息,因此你会发现,在这个过程中我们丢弃了大量的数据。关于类的信息都保存在classNameTable和offsetTable中。在读取常量池之后,就找到了this_class信息,这是offsetTable的一个坐标,通过它可以找到进入classNameTable的坐标,然后就可以得到我们所需的类的名字了。

1.4.2. 使用@Unit

下面是一个简单的例子:


package annotations;
import atunit.Test;
public class AtUnitExample1 { public String methodOne() { return "This is methodOne"; }
public int methodTwo() { System.out.print("This is methodTwo"); return 2; }
@Test boolean methodOneTest() { return methodOne().endsWith("This is methodOne"); }
@Test boolean m2() { return methodTwo() == 2; }
@Test private boolean m3() { return true; }
// 展示失败的输出 @Test boolean failureTest() { return false; }
@Test boolean anotherDisappointment() { return false; }}


执行下面的命令,会得到输出:


shell> javac atunit/*.javashell> javac annotations/*.javashell> java atunit.AtUnit annotations/AtUnitExample1.classUnitExample1.classannotations.AtUnitExample1 . methodOneTest . m2 This is methodTwo . m3 . failureTest (failed) . anotherDisappointment (failed)(5tests)
>>>2 FAILURES<<< annotations.AtUnitExample1: failureTest annotations.AtUnitExample1: anotherDisappointment


使用 @Unit 进行测试的类必须定义在某个包中。

你并非必须将测试方法嵌入到原来的类中,有时候这种事情根本做不到。要生产一个非嵌入式的测试,最简单的方式就是继承:


package annotations;
import atunit.Test;
public class AUExternalTest extends AtUnitExample1 {
@Test boolean _MethodOne() { return methodOne().equals("This is methodOne"); }

@Test boolean _MethodTwo() { return methodTwo() == 2; }}


执行下面的命令,会得到输出:


shell> javac annotations/AUExternalTest.javashell> java atunit.AtUnit annotations/AUExternalTest.classannotations.AUExternalTest . _MethodOne . _MethodTwo This is methodTwoOK(2tests)


也可以使用组合来创建非嵌入式的测试:


package annotations;
import atunit.Test;
public class AuComposition {
AtUnitExample1 testObject = new AtUnitExample1();
@Test boolean tMethodOne() { return testObject.methodOne().equals("This is methodOne"); }
@Test boolean tMethodTwo(){ return testObject.methodTwo() == 2; }}


执行下面的命令,会得到输出:


shell> javac annotations/AuComposition.javashell> java atunit.AtUnit annotations/AuComposition.classannotations.AuComposition . tMethodOne . tMethodTwo This is methodTwoOK(2tests)


为了表示测试成功,可以使用Java的assert语句。想使用Java的断言机制需要在java命令行中加上-ea标志来开启,不过@Unit 已经自动开启了该功能。一个失败的assert或者从方法抛出的异常都被视为测试失败,但是@Unit不会在这个失败的测试上卡住,它会继续运行,直到所有测试完毕,下面是一个示例程序:


package annotations;import atunit.Test;
import java.io.FileInputStream;import java.io.IOException;
public class AtUnitExample2 { public String methodOne() { return "This is methodOne"; }
public int methodTwo() { System.out.println("This is methodTwo"); return 2; }
@Test void assertExample() { assert methodOne().equals("This is methodOne"); }
@Test void assertFailureExample() { assert 1 == 2 : "What a surprise!"; }
@Test void exceptionExample() throws IOException { try ( FileInputStream in = new FileInputStream("nofile.txt") ) { } }
@Test boolean assertAndReturn() { assert methodTwo() == 2 : "methodTwo must equal 2"; return methodOne().equals("This is methodOne"); }}


执行下面的命令,会得到输出:


shell> javac annotations/AtUnitExample2.javashell> java atunit.AtUnit annotations/AtUnitExample2.classannotations.AtUnitExample2 . assertExample . assertFailureExample java.lang.AssertionError: What a surprise!(failed) . exceptionExample java.io.FileNotFoundException: nofile.txt (No such file or directory)(failed) . assertAndReturn This is methodTwo
(4tests)
>>>2 FAILURES<<< annotations.AtUnitExample2: assertFailureExample annotations.AtUnitExample2: exceptionExample


下是一个使用非嵌入式测试的例子,并且使用了断言,它将会对java.util.HashSet进行一些简单的测试:


package annotations;
import atunit.Test;import java.util.HashSet;
public class HashSetTest { HashSet<String> testObject = new HashSet<>();
@Test void initialization(){ assert testObject.isEmpty(); }
@Test void _Contains(){ testObject.add("one"); assert testObject.contains("one"); }
@Test void _Remove(){ testObject.add("one"); testObject.remove("one"); assert testObject.isEmpty(); }}


对每一个单元测试而言,@Unit都会使用默认的无参构造器,为该测试类所属的类创建出一个新的实例,并在此新创建的对象上运行测试,然后丢弃该对象,以免对其他测试产生副作用。如此创建对象导致我们依赖于类的默认构造器。


如果你的类没有默认构造器,或者对象需要复杂的构造过程,那么你可以创建一个static方法专门负责构造对象,然后使用@TestObjectCreate注解标记该方法。@TestObjectCreate修饰的方法必须声明为static,并且必须返回一个你正在测试的类型对象,这一切都由@Unit负责确保成立。例子如下:


package annotations;
import atunit.Test;import atunit.TestObjectCreate;
public class AtUnitExample3 { private int n;
public AtUnitExample3(int n) { this.n = n; }
public int getN(){ return n; }
public String methodOne(){ return "This is methodOne"; }
public int methodTwo(){ System.out.print("This is methodTwo"); return 2; }
@TestObjectCreate static AtUnitExample3 create(){ return new AtUnitExample3(47); }
@Test boolean initialization(){ return n == 47; }
@Test boolean methodOneTest(){ return methodOne().equals("This is methodOne"); }
@Test boolean m2(){ return methodTwo() == 2; }}


执行下面的命令,会得到输出:


shell> javac annotations/AtUnitExample3.javashell> java atunit.AtUnit annotations/AtUnitExample3.classUnitExample3.classannotations.AtUnitExample3 . initialization  . methodOneTest  . m2 This is methodTwoOK(3tests)


有的时候,你需要向单元测试中增加一些字段。这时候可以使用@TestProperty注解,由它注解的字段表示只在单元测试中使用(因此,在你将产品发布给客户之前,他们应该被删除)。@TestProperty也可以用来标记那些只在测试中使用的方法,但是它们本身不是测试方法。在下面的例子中,一个String通过String.split()方法进行分割,从其中读取一个值,这个值将会被生成测试对象:


package annotations;
import atunit.Test;import atunit.TestObjectCreate;import atunit.TestProperty;import java.util.*;
public class AtUnitExample4 { static String theory = "All brontosauruses are thin at one end, much MUCH thicker in the middle, and then thin again at the far end.";
private String word; private Random random = new Random();
public AtUnitExample4(String word) { this.word = word; }
public String getWord() { return word; }
public String scrambleWorld() { List<Character> chars = new ArrayList<>(); for(Character c: word.toCharArray()){ chars.add(c); } Collections.shuffle(chars, random);
StringBuilder result = new StringBuilder(); for (char ch : chars) { result.append(ch); }
return result.toString(); }
@TestProperty static List<String> input = Arrays.asList(theory.split(" "));
@TestProperty static Iterator<String> words = input.iterator();
@TestObjectCreate static AtUnitExample4 create(){ if(words.hasNext()){ return new AtUnitExample4(words.next()); } return null; }
@Test boolean words(){ System.out.println("'" + getWord() + "'"); return getWord().equals("are"); }
@Test boolean scramble1(){ random = new Random(47); System.out.println("'" + getWord() + "'"); String scrambled = scrambleWorld(); System.out.println(scrambled); return scrambled.equals("1A1"); }
@Test boolean scramble2(){ random = new Random(74); System.out.println("'" + getWord() + "'"); String scrambled = scrambleWorld(); System.out.println(scrambled); return scrambled.equals("tsaeborornussu"); }}


执行下面的命令,会得到输出:


shell> javac annotations/AtUnitExample4.javashell> java atunit.AtUnit annotations/AtUnitExample4.classannotations.AtUnitExample4 . words 'All'(failed) . scramble1 'brontosauruses'ntsaueorosurbs(failed) . scramble2 'are'are(failed)(3tests)
>>>3 FAILURES<<< annotations.AtUnitExample4: words annotations.AtUnitExample4: scramble1 annotations.AtUnitExample4: scramble2


如果你的测试对象需要执行某些初始化工作,并且使用完成之后还需要执行清理工作,那么可以选择使用static的 @TestObjectCleanup方法,当测试对象使用结束之后,该方法会为你执行清理工作。在下面的示例中,@TestObjectCleanup为每一个测试对象都打开了一个文件,因此必须在丢弃测试的时候关闭该文件:


package annotations;
import atunit.Test;import atunit.TestObjectCleanup;import atunit.TestObjectCreate;import atunit.TestProperty;
import java.io.IOException;import java.io.PrintWriter;
public class AtUnitExample5 { private String text;
public AtUnitExample5(String text) { this.text = text; }
@Override public String toString() { return text; }
@TestProperty static PrintWriter output;
@TestProperty static int counter;
@TestObjectCreate static AtUnitExample5 create(){ String id = Integer.toString(counter++); try{ output = new PrintWriter("Test" + id +".txt"); }catch (IOException e){ throw new RuntimeException(e); }
return new AtUnitExample5(id); }
@TestObjectCleanup static void cleanup(AtUnitExample5 tobj){ System.out.println("Running cleanup"); output.close(); }
@Test boolean test1(){ output.print("test1"); return true; }
@Test boolean test2(){ output.print("test2"); return true; }
@Test boolean test3(){ output.print("test3"); return true; }}


执行下面的命令,会得到输出:


shell> javac annotations/AtUnitExample5.javashell> java atunit.AtUnit annotations/AtUnitExample5.classannotations.AtUnitExample5annotations.AtUnitExample5 . test1 Running cleanup . test2 Running cleanup . test3 Running cleanupOK(3tests)

1.4.3. 在@Unit中使用泛型

泛型为 @Unit 出了一个难题,因为我们不可能“通用测试”。我们必须针对某个特定类型的参数或者参数集才能进行测试。解决方法十分简单,让测试类继承自泛型类的一个特定版本即可。下面是一个 stack 的简单实现:


package annotations;
import java.util.LinkedList;
public class StackL<T> { private LinkedList<T> list = new LinkedList<>(); public void push(T v) { list.addFirst(v); }
public T top(){ return list.getFirst(); }
public T pop(){ return list.removeFirst(); }}


为了测试 String 版本,我们直接让测试类继承一个Stack


package annotations;
import atunit.Test;
public class StackLStringTst extends StackL<String> {
@Test void tPush() { push("one"); assert top().equals("one"); push("two"); assert top().equals("two"); }
@Test void tPop() { push("one"); push("two"); assert pop().equals("two"); assert pop().equals("one"); }
@Test void tTop() { push("A"); push("B"); assert top().equals("B"); assert top().equals("B"); }}


这种方法存在的唯一缺点是,继承使我们失去了访问被测试的类中private方法的能力。如果你需要测试private方法,那你要么把private方法变为protected,要么添加一个非private的@TestProperty方法,由它来调用private方法


下面是输出:

shell> javac annotations/StackLStringTst.javashell> java atunit.AtUnit annotations/StackLStringTst.classannotations.StackLStringTst . tPush  . tTop  . tPop OK(3tests)