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.*;
(ElementType.METHOD)
(RetentionPolicy.RUNTIME)
public Test { }
现在来使用这个@Test:
package annotations;
import atunit.Test;
public class Testable {
public void execute() {
System.out.println("Executing..");
}
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;
(ElementType.METHOD)
(RetentionPolicy.RUNTIME)
public UseCase {
public int id();
public String description() default "no description";
}
在下面的类中,有三个方法被注解为用例:
package annotations;
import java.util.List;
public class PasswordUtils {
47, description = "...") (id=
public boolean validatePassword(String password) {
return password.matches("\\w*\\d\\w*");
}
48) (id=
public String encryptPassword(String password) {
return new StringBuilder(password).reverse().toString();
}
49, description = "...") (id=
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: 47
Found Use Case: 48
Found Use Case: 49
Warning: 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;
(ElementType.TYPE)
(RetentionPolicy.RUNTIME)
public DBTable {
// 表的名字
public String name() default "";
}
在@Target注解中指定的每个ElementType就是一个约束,如果想要指定多个值需要用逗号隔开,而如果想将注解应用于所有的ElementType,可以省略@Target元注解。
下面是修饰JavaBean域的三个注解:
// annotations/Constraints.java
package annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
(ElementType.FIELD)
(RetentionPolicy.RUNTIME)
public Constraints {
boolean primaryKey() default false;
boolean allowNull() default true;
boolean unique() default false;
}
// annotations/SQLString.java
package annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
(ElementType.FIELD)
(RetentionPolicy.RUNTIME)
public SQLString {
int value() default 0;
String name() default "";
Constraints constraints() default @Constraints;
}
// annotations/SQLInteger.java
package annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
(ElementType.FIELD)
(RetentionPolicy.RUNTIME)
public SQLInteger {
String name() default "";
Constraints constraints() default @Constraints;
}
注解处理器通过@Constraints注解提取出数据库的元数据,后两个定义了两种SQL类型作为示例。如果想要让嵌入的@Constraints注解中的unique()元素为true,并以此作为constraints()元素的默认值,就需要像这样:Constraints constraint() default @Constraints(unique = true);
然后是一个简单的Bean定义,我们在其中应用了上面的注解:
package annotations;
"MEMBER") (name =
public class Member {
30) (
String firstName;
50) (
String lastName;
Integer age;
30, constraints = (primaryKey = true)) (value =
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;
(RetentionPolicy.SOURCE)
({ElementType.TYPE, ElementType.METHOD,
ElementType.CONSTRUCTOR,
ElementType.ANNOTATION_TYPE,
ElementType.PACKAGE, ElementType.FIELD,
ElementType.LOCAL_VARIABLE})
public Simple {
String value() default "-default-";
}
@Retention的参数现在为SOURCE,这意味着注解不会存留在编译后的代码。这在编译时处理注解是没有必要的,它只是指出,在这里,javac是唯一有机会处理注解的代理。@Target声明了几乎所有的目标类型(除了PACKAGE),同样是为了演示。
现在来使用这个注解:
package annotations;
public class SimpleTest {
private int i;
public SimpleTest() {
}
public void foo() {
System.out.println("SimpleTest.foo()");
}
public void bar(String s, int i, float f) {
System.out.println("SimpleTest.bar()");
}
public static void main(String[] args) {
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;
"annotations.Simple") (
(SourceVersion.RELEASE_8)
public class SimpleProcessor extends AbstractProcessor {
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.SimpleTest
annotations.SimpleTest
java.lang.Object
i,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:()void
void
foo
====== bar(java.lang.String,int,float) ======
METHOD:[public]:bar:(java.lang.String,int,float)void
void
bar
s,i,f
====== main(java.lang.String[]) ======
METHOD:[public, static]:main:(java.lang.String[])void
void
main
args
1.3.2. 更复杂的处理器
当你创建用于javac的注解处理器时,你不能使用Java的反射特性,因为你处理的是源代码,而并非是编译后的class文件。各种 mirror解决这个问题的方法是,通过允许你在未编译的源代码中查看方法、字段和类型。
如下是一个用于提取类中方法的注解,所以它可以被抽取成为一个接口:
package annotations;
import java.lang.annotation.*;
(ElementType.TYPE)
(RetentionPolicy.SOURCE)
public ExtractInterface {
String interfaceName() default "-!!-";
}
接下来的测试类提供了一些公用方法,这些方法可以成为接口的一部分:
package annotations;
"IMultiplier") (interfaceName =
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.*;
"annotations.ExtractInterface") (
(SourceVersion.RELEASE_8)
public class IfaceExtractorProcessor extends AbstractProcessor {
private ArrayList<Element>
interfaceMethods = new ArrayList<>();
private Elements elementUtils;
private ProcessingEnvironment processingEnv;
public void init(
ProcessingEnvironment processingEnv) {
this.processingEnv = processingEnv;
elementUtils = processingEnv.getElementUtils();
}
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.java
package atunit;
import java.lang.annotation.*;
(ElementType.METHOD)
(RetentionPolicy.RUNTIME)
public TestObjectCleanup {
}
// TestObjectCreate.java
package atunit;
import java.lang.annotation.*;
(ElementType.METHOD)
(RetentionPolicy.RUNTIME)
public TestObjectCreate {
}
// TestProperty.java
package atunit;
import java.lang.annotation.*;
({ElementType.METHOD, ElementType.FIELD})
(RetentionPolicy.RUNTIME)
public 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);
}
}
}
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;
}
boolean methodOneTest() {
return methodOne().endsWith("This is methodOne");
}
boolean m2() {
return methodTwo() == 2;
}
private boolean m3() {
return true;
}
// 展示失败的输出
boolean failureTest() {
return false;
}
boolean anotherDisappointment() {
return false;
}
}
执行下面的命令,会得到输出:
javac atunit/*.java
javac annotations/*.java
java atunit.AtUnit annotations/AtUnitExample1.class
UnitExample1.class
annotations.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 {
boolean _MethodOne() {
return methodOne().equals("This is methodOne");
}
boolean _MethodTwo() {
return methodTwo() == 2;
}
}
执行下面的命令,会得到输出:
javac annotations/AUExternalTest.java
java atunit.AtUnit annotations/AUExternalTest.class
annotations.AUExternalTest
. _MethodOne
. _MethodTwo This is methodTwo
OK(2tests)
也可以使用组合来创建非嵌入式的测试:
package annotations;
import atunit.Test;
public class AuComposition {
AtUnitExample1 testObject = new AtUnitExample1();
boolean tMethodOne() {
return testObject.methodOne().equals("This is methodOne");
}
boolean tMethodTwo(){
return testObject.methodTwo() == 2;
}
}
执行下面的命令,会得到输出:
javac annotations/AuComposition.java
java atunit.AtUnit annotations/AuComposition.class
annotations.AuComposition
. tMethodOne
. tMethodTwo This is methodTwo
OK(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;
}
void assertExample() {
assert methodOne().equals("This is methodOne");
}
void assertFailureExample() {
assert 1 == 2 : "What a surprise!";
}
void exceptionExample() throws IOException {
try (
FileInputStream in = new FileInputStream("nofile.txt")
) {
}
}
boolean assertAndReturn() {
assert methodTwo() == 2 : "methodTwo must equal 2";
return methodOne().equals("This is methodOne");
}
}
执行下面的命令,会得到输出:
javac annotations/AtUnitExample2.java
java atunit.AtUnit annotations/AtUnitExample2.class
annotations.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<>();
void initialization(){
assert testObject.isEmpty();
}
void _Contains(){
testObject.add("one");
assert testObject.contains("one");
}
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;
}
static AtUnitExample3 create(){
return new AtUnitExample3(47);
}
boolean initialization(){
return n == 47;
}
boolean methodOneTest(){
return methodOne().equals("This is methodOne");
}
boolean m2(){
return methodTwo() == 2;
}
}
执行下面的命令,会得到输出:
javac annotations/AtUnitExample3.java
java atunit.AtUnit annotations/AtUnitExample3.class
UnitExample3.class
annotations.AtUnitExample3
. initialization
. methodOneTest
. m2 This is methodTwo
OK(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();
}
static List<String> input = Arrays.asList(theory.split(" "));
static Iterator<String> words = input.iterator();
static AtUnitExample4 create(){
if(words.hasNext()){
return new AtUnitExample4(words.next());
}
return null;
}
boolean words(){
System.out.println("'" + getWord() + "'");
return getWord().equals("are");
}
boolean scramble1(){
random = new Random(47);
System.out.println("'" + getWord() + "'");
String scrambled = scrambleWorld();
System.out.println(scrambled);
return scrambled.equals("1A1");
}
boolean scramble2(){
random = new Random(74);
System.out.println("'" + getWord() + "'");
String scrambled = scrambleWorld();
System.out.println(scrambled);
return scrambled.equals("tsaeborornussu");
}
}
执行下面的命令,会得到输出:
javac annotations/AtUnitExample4.java
java atunit.AtUnit annotations/AtUnitExample4.class
annotations.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;
}
public String toString() {
return text;
}
static PrintWriter output;
static int counter;
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);
}
static void cleanup(AtUnitExample5 tobj){
System.out.println("Running cleanup");
output.close();
}
boolean test1(){
output.print("test1");
return true;
}
boolean test2(){
output.print("test2");
return true;
}
boolean test3(){
output.print("test3");
return true;
}
}
执行下面的命令,会得到输出:
javac annotations/AtUnitExample5.java
java atunit.AtUnit annotations/AtUnitExample5.class
annotations.AtUnitExample5
annotations.AtUnitExample5
. test1
Running cleanup
. test2
Running cleanup
. test3
Running cleanup
OK(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> {
void tPush() {
push("one");
assert top().equals("one");
push("two");
assert top().equals("two");
}
void tPop() {
push("one");
push("two");
assert pop().equals("two");
assert pop().equals("one");
}
void tTop() {
push("A");
push("B");
assert top().equals("B");
assert top().equals("B");
}
}
这种方法存在的唯一缺点是,继承使我们失去了访问被测试的类中private方法的能力。如果你需要测试private方法,那你要么把private方法变为protected,要么添加一个非private的@TestProperty方法,由它来调用private方法
下面是输出:
javac annotations/StackLStringTst.java
java atunit.AtUnit annotations/StackLStringTst.class
annotations.StackLStringTst
. tPush
. tTop
. tPop
OK(3tests)