Annotation

注解(又叫作元数据)使我们可以用正式的方式为代码添加信息,这样就可以在将来方便地使用这些数据。

注解的出现,部分原因是为了迎合将元数据绑定到源代码文件(而非保存在额外的文档中)的趋势。同时Java也受到了来自其他语言(如C#)特性的压力,这也是对此的一个回应。

注解是Java 5的一项重要语言更新。它提供了用Java无法表达、却是完整表述程序所需的信息。因此,注解使你可以用某种格式来保存和程序有关的额外信息,编译器会验证该格式的正确性。注解可以生成描述符文件,甚至还可以生成新的类定义,并帮助你减轻编写“样板”代码的负担。通过注解,可以将这些元数据保存在Java源代码中,并拥有以下优势:

  • 更整洁的代码;

  • 编译时的类型检查;

  • 为注解构建处理工具的注解API。

虽然Java中预定义了几种类型的元数据,但通常来说,要添加什么样的注解类型,以及用它们来做什么,完全由你决定。

注解的语法十分简单,主要是在语言中添加@符号。Java 5引入了第一批定义在java.lang中的3个通用内建注解。

  • @Override:用来声明该方法的定义会重写基类中的某个方法。如果不小心拼错了方法名,或者使用了不恰当的签名,该注解会使编译器报错。2
  • @Deprecated:如果该元素被使用了,则编译器会发出警告。
  • @SuppressWarnings:关闭不当的编译警告。

以下是Java 7和Java 8新增的注解:

  • @SafeVarargs:Java 7引入,用于在使用泛型作为可变参数的方法或构造器中关闭对调用者的警告。
  • @FunctionalInterface:Java 8引入,用于表明类型声明是函数式接口。
    另外还有5个注解类型用于创建新注解,你将在本章学习它们。
    每当你创建涉及重复工作的类或接口时,通常都可以用注解来自动化及简化该过程。例如Enterprise JavaBeans(EJB)中的许多额外工作已被EJB3中的注解替代。

注解可以替代一些已有系统,如XDoclet(一个创建注解风格文档的独立文档工具)。对比来看,注解是真正的语言组件,因此是结构化的,并可接受编译时类型检查。将所有信息都保存在真正的代码而不是注释中,会使代码更整洁,且更便于维护。通过直接使用或扩展注解API和工具,或者使用外部的字节码处理库(如本章后面所述),可以对源代码以及字节码执行强大的检查和操作。

4.1 基本语法

在下面的示例中,testExecute()方法添加了@Test注解。该注解本身并不会做任何事,只是编译器会确保在CLASSPATH中存在@Test注解的定义。本章稍后会创建一个通过反射来运行该方法的工具。

// annotations/Testable.java
package annotations;
import onjava.atunit.*;

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

增加了注解的方法和其他方法并无区别,本例中的@Test注解可以和任何修饰符一起配合使用,如publicstaticvoid。从语法角度看,注解的使用方式和修饰符基本相同。

4.1.1 定义注解

以下代码是对前文中注解的定义。注解的定义看起来非常像接口的定义。实际上,注解和任何其他Java接口一样,也会编译成类文件。

// onjava/atunit/Test.java
// @Test标签
package onjava.atunit;
import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {}

除了@符号外,@Test的定义非常像一个空接口。注解的定义也要求必须有元注解@Target@Retention@Target定义了你可以在何处应用该注解(例如方法或字段)。@Retention定义了该注解在源代码(SOURCE)、类文件(CLASS)或运行时(RUNTIME)中是否可用。

注解通常包含一些可以设定值的元素。程序或工具在处理注解时可以使用这些参数。元素看起来比较像接口方法,只不过你可以为其指定默认值。

没有任何元素的注解(如上面的@Test)称为标记注解

下面是一个用于跟踪某项目中用例的简单注解,程序员会给某个特定用例所需的所有方法或方法集都加上注解。项目经理可以通过计算已实现的用例数来了解项目的进度,维护项目的开发人员可以轻松地找到需要更新的用例,或者在系统内调试业务规则。

// annotations/UseCase.java
import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UseCase {
  int id();
  String description() default "no description";
}

注意,iddescription与方法声明很相似。因为id会受到编译器的类型检查,所以用这种方式将用于跟踪进展的数据库关联到用例文档和源代码,是很可靠的。description元素有个默认值,如果在方法被注解时未指定该元素的值,则注解处理器会使用该默认值。

看一下以下这个类,其中的3个方法被注解为用例:

// annotations/PasswordUtils.java
import java.util.*;

public class PasswordUtils {
  @UseCase(id = 47, description =
  "Passwords must contain at least one numeric")
  public boolean validatePassword(String passwd) {
    return (passwd.matches("\\w*\\d\\w*"));
  }
  @UseCase(id = 48)
  public String encryptPassword(String passwd) {
   return new StringBuilder(passwd)
    .reverse().toString();
  }
  @UseCase(id = 49, description =
  "New passwords can't equal previously used ones")
  public boolean checkForNewPassword(
    List<String> prevPasswords, String passwd) {
    return !prevPasswords.contains(passwd);
  }
}

注解元素定义值的方式是,在@UseCase声明后的圆括号中,用“名-值”对形式来表示。此处encryptPassword()方法的注解并未给description元素传入值,因此当类经过注解处理器的处理后,@interface UseCase中定义的默认值便会出现在此处。

想象一下,你可以先用这种方法来“勾勒”出你的系统,然后在构建时逐渐完善其功能。

4.1.2 元注解

Java语言中目前只定义了5个标准注解(前面已介绍)和5个元注解(见表4-1)。元注解是为了对注解进行注解。

表4-1

注解 效果
@Target 该注解可应用的地方。 可能的ElementType参数包括: - CONSTRUCTOR——构造器声明 - FIELD——字段声明(包括枚举常量) - LOCAL_VARIABLE——本地变量声明 - METHOD——方法声明 - PACKAGE——包声明 - PARAMETRE——参数声明 - TYPE——类、接口(包括注解类型)或枚举的声明
@Retention 注解信息可以保存多久。 可能的RetentionPolicy参数包括: - SOURCE——注解会被编译器丢弃 - CLASS——注解在类文件中可被编译器使用,但会被虚拟机丢弃 - RUNTIME——注解在运行时仍被虚拟机保留,因此可以通过反射读取到注解信息
@Documented 在Javadoc中引入该注解
@Inherited 允许子类继承父注解
@Repeatable 可以多次应用于同一个声明(Java 8)

大多数时候,你可以定义自己的注解,然后自行编写处理器来处理它们。

4.2 编写注解处理器

如果没有工具来读取注解,那它实际并不会比注释带来更多帮助。使用注解的过程中,很重要的一点是创建并使用注解处理器。Java为反射API提供了扩展,以帮助创建这些工具。Java同时还提供了一个javac编译器钩子,用来在编译时使用注解。

以下示例是一个非常简单的注解处理器,它读取被注解的PasswordUtils(密码工具)类,然后利用反射来查找@UseCase标签。通过给定的id值列表,该注解列出了它找到的所有用例,并报告所有丢失的用例。

// annotations/UseCaseTracker.java
import java.util.*;
import java.util.stream.*;
import java.lang.reflect.*;

public class UseCaseTracker {
  public static void trackUseCases(List<Integer> useCases, Class<?> cl) {
    for(Method m : cl.getDeclaredMethods()) {
      UseCase uc = m.getAnnotation(UseCase.class);
      if(uc != null) {
        System.out.println("Found Use Case " +
          uc.id() + "\n  " + uc.description());
        useCases.remove(Integer.valueOf(uc.id()));
      }
    }
    useCases.forEach(i ->
      System.out.println("Missing use case " + i));
  }
  public static void main(String[] args) {
    List<Integer> useCases = IntStream.range(47, 51)
      .boxed().collect(Collectors.toList());
    trackUseCases(useCases, PasswordUtils.class);
  }
}
/* 输出:
Found Use Case 49
  New passwords can't equal previously used ones
Found Use Case 48
  no description
Found Use Case 47
  Passwords must contain at least one numeric
Missing use case 50
*/

此处同时使用了反射方法getDeclaredMethods()和从AnnotatedElement接口(诸如ClassMethod以及Field等这样的类都会实现该接口)中继承实现的getAnnotation()方法,该方法返回指定类型的注解对象,在本例中即UseCase。如果在此注解方法上没有该指定类型的注解,将会返回null。元素的值通过调用id()description()方法提取出来。注意,encryptPassword()方法的注解中并未指定description描述,因此当在该注解上调用description()方法时,上述处理器会调取默认值no description

4.2.1 注解元素

在之前的UseCase.java中定义了@UseCase标签,其中包含int元素idString元素description。以下列出了注解所允许的所有元素类型:

  • 所有的基本类型(intfloatboolean等)
  • String(字符串)
  • Class(类)
  • enum(枚举)
  • Annotation(注解)
  • 以上任何类型的数组

如果尝试使用任何其他类型,编译器都会报错。注意,任何包装类都是不允许使用的,但由于有自动装箱机制,因此这实际上并不会造成限制。注解也可以作为元素的类型,正如你稍后会看到的,内嵌注解是非常有用的。

4.2.2 默认值的限制

编译器对元素的默认值要求非常苛刻。所有元素都需要有确定的值,这意味着元素要么有默认值,要么由使用该注解的类来设定值。

还有另一个限制:不论是在源代码中声明时,还是在注解中定义默认值时,任何非基本类型元素都不能赋值为null。这导致很难让处理器去判断某个元素存在与否,因为所有元素在所有注解声明中都是有效存在的。但可以通过检查该元素是否为特殊值(如空字符串或负值)来绕过这个限制:

// annotations/SimulatingNull.java
import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SimulatingNull {
  int id() default -1;
  String description() default "";
}

这是定义注解时的一个经典技巧。

4.2.3 生成外部文件

有些框架要求一些额外信息来配合源代码共同工作,在使用这种框架时,注解特别有用。诸如Enterprise JavaBeans(即EJB)这样的技术(在EJB3出现之前)需要大量的接口和部署描述文件作为“样板”代码,它们以相同的方式为每个bean进行定义。Web服务、自定义标签库,以及Toplink、Hibernate等对象/关系映射工具(ORM)通常也需要代码之外的XML描述文件。每定义一个Java类,程序员都必须经过一个乏味的配置信息的过程,比如配置类名、包名等——这些都是类中本来就有的信息。无论你什么时候使用外部描述符文件,最终都会得到关于一个类的两个独立的信息源,这常常导致代码的信息同步问题。同时这也要求该项目的程序员除了写Java程序外,还必须知道如何编写这些描述符文件。

假如你想实现一套基本的ORM功能来自动化数据库表的创建,你便可以通过XML描述符文件来指定类名、类中的每一个成员,以及数据库映射信息。而如果使用注解,你可以将所有的信息都维护在单个源代码文件中。要实现此功能,你需要注解来定义数据库表名、字段信息,以及要映射到属性的SQL类型。

以下示例是一个注解,它会让注解处理器创建一个数据库表:

// annotations/database/DBTable.java
package annotations.database;
import java.lang.annotation.*;

@Target(ElementType.TYPE) // 只适用于类
@Retention(RetentionPolicy.RUNTIME)
public @interface DBTable {
  String name() default "";
}

@Target注解中指定的每个ElementType(元素类型)都是一条约束,它告诉编译器注解只能被应用于该特定类型。你可以指定一个单值的enum元素类型,也可以指定一个用逗号分隔的任意值组成的列表。如果想将注解应用于所有的ElementType,则可以将@Target注解全部去掉,虽然这么做不太常见。

注意,@DBTable中有个name()元素,该注解可以通过它为处理器要创建的数据库表指定表名。

以下示例是表字段的注解:

// annotations/database/Constraints.java
package annotations.database;
import java.lang.annotation.*;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Constraints {
  boolean primaryKey() default false;
  boolean allowNull() default true;
  boolean unique() default false;
}
// annotations/database/SQLString.java
package annotations.database;
import java.lang.annotation.*;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SQLString {
  int value() default 0;
  String name() default "";
  Constraints constraints() default @Constraints;
}
// annotations/database/SQLInteger.java
package annotations.database;
import java.lang.annotation.*;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SQLInteger {
  String name() default "";
  Constraints constraints() default @Constraints;
}

@Constraints注解使得处理器可以提取出数据库表的元数据,这相当于数据库提供的一个小的约束子集,不过它可以帮助你形成一个整体的概念。通过为primaryKey()allowNull()unique()元素设置合理的默认值,可以帮使用者减少很多编码工作。

另外两个@interface用于定义SQL的类型。同样,为了使该框架更好用,可以为每个额外的SQL类型都定义一个注解。在本例中,两个注解就足够了。

这些类型都有一个name()元素和一个constraints()元素,后者利用嵌套注解的特性嵌入字段类型的数据库约束信息。注意constraints()元素的默认值是@Constraints。该注解类型后面的圆括号中没有指定元素值,因此constraints()的默认值实际上是一个自身带有一套默认值的@Constraints注解。如果想将内嵌的@Constraints注解的唯一性默认设置为true,可以像下面这样定义它的元素:

// annotations/database/Uniqueness.java
// 嵌套注解示例:
package annotations.database;

public @interface Uniqueness {
  Constraints constraints()
  default @Constraints(unique = true);
}

以下示例是一个使用了该注解的简单类:

// annotations/database/Member.java
package annotations.database;

@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 reference;
  static int memberCount;
  public String getReference() { return reference; }
  public String getFirstName() { return firstName; }
  public String getLastName() { return lastName; }
  @Override public String toString() {
    return reference;
  }
  public Integer getAge() { return age; }
}

类注解@DBTable被赋值为MEMBER,以用作表名。属性firstNamelastName都被注解为@SQLString,并且分别被赋值为30和50。这些注解很有意思,原因有二:首先,它们都用到了内嵌注解@Constraints中的默认值;其次,它们使用了快捷格式——如果将注解中的元素名定义为value,那么只要它是唯一指定的元素类型,就无须使用“名-值”对的语法,只需要在圆括号内直接指定该值即可。这种方式适用于任何合法的元素类型,该方法限制你必须将元素命名为“value”,不过在如前所述的情况下,这确实促成了有意义且易读的注解规范。

@SQLString(30)

处理器会用该值来设定待创建的SQL字段的长度。

默认值的语法虽然简洁,但很快就会变得复杂起来。来看看字段reference上的注解,其中有一个@SQLString注解,但它同时又必须是数据库的主键,因此在内嵌注解@Constraint中必须设置元素类型primaryKey。麻烦就在这里,现在你不得不在内嵌注解中使用相当冗长的“名-值”对格式,重新指定元素名和@interface的名称。但是因为被特殊命名的元素value不再是唯一被指定的元素值,所以你无法继续使用快捷格式。正如你所看到的,最终结果并不优雅。

替换方案

对于这个问题,还有其他方法来创建注解。举例来说,可以写一个叫@TableColumn的注解类,其中包含一个enum元素,来定义诸如STRINGINTEGERFLOAT这样的值。这样就不再需要为每个SQL类型都写一个@interface了,但也使你无法再用size(长度)或precision(精度)等额外的元素来进一步修饰类型,而这些可能会更有用。

你也可以使用String元素来描述实际的SQL类型(比如VARCHAR(30)INTEGER)。这使得你可以修饰类型,却将Java类型和SQL类型的映射关系在代码中绑定了,这并非好的设计。你肯定不想每当数据库有变化时就重新编译一遍代码。更优雅的方法是,告诉注解处理器你需要什么“口味”(flavor)的SQL,然后处理器在执行时再来处理这些细节。

第三种可行的方法是同时使用两个注解类型来注解目标字段——@Constraints和相应的SQL类型(比如@SQLInteger)。这不太优雅,但是只要你需要,编译器就允许对目标增加任意个注解。在Java 8中使用多注解时,同一个注解可以重复使用。

4.2.4 注解不支持继承

我们无法对@interface使用extends关键字。这很可惜,一套优雅的方案应该像之前所建议的一样,定义一个注解@TableColumn,该注解内部包含一个内嵌注解@SQLType,由此就可以从@SQLType继承所有的SQL类型,如@SQLInteger@SQLString。这样可以减少编码工作,并使语法更简洁。目前看不到Java未来版本中要支持注解继承的迹象,在这种情况下,上面这个例子应该是你目前的最佳选择了。

4.2.5 实现处理器

下面这个示例演示了注解处理器如何读取类文件,检查其数据库注解,并生成SQL命令来创建数据库:

// annotations/database/TableCreator.java
// 基于反射的注解处理器
// {java annotations.database.TableCreator
// annotations.database.Member}
package annotations.database;
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;

public class TableCreator {
  public static void
  main(String[] args) throws Exception {
    if(args.length < 1) {
      System.out.println(
        "arguments: annotated classes");
      System.exit(0);
    }
    for(String className : args) {
      Class<?> cl = Class.forName(className);
      DBTable dbTable = cl.getAnnotation(DBTable.class);
      if(dbTable == null) {
        System.out.println(
          "No DBTable annotations in class " +
          className);
        continue;
      }
      String tableName = dbTable.name();
      // 如果name为空,则使用Class name:
      if(tableName.length() < 1)
        tableName = cl.getName().toUpperCase();
      List<String> columnDefs = new ArrayList<>();
      for(Field field : cl.getDeclaredFields()) {
        String columnName = null;
        Annotation[] anns =
          field.getDeclaredAnnotations();
        if(anns.length < 1)
          continue; // 不是数据库表字段
        if(anns[0] instanceof SQLInteger) {
          SQLInteger sInt = (SQLInteger) anns[0];
          // 如果name未指定,使用字段名
          if(sInt.name().length() < 1)
            columnName = field.getName().toUpperCase();
          else
            columnName = sInt.name();
          columnDefs.add(columnName + " INT" +
            getConstraints(sInt.constraints()));
        }
        if(anns[0] instanceof SQLString) {
          SQLString sString = (SQLString) anns[0];
          // 如果name未指定,使用字段名
          if(sString.name().length() < 1)
            columnName = field.getName().toUpperCase();
          else
            columnName = sString.name();
          columnDefs.add(columnName + " VARCHAR(" +
            sString.value() + ")" +
            getConstraints(sString.constraints()));
        }
        StringBuilder createCommand = new StringBuilder(
          "CREATE TABLE " + tableName + "(");
        for(String columnDef : columnDefs)
          createCommand.append(
            "\n    " + columnDef + ",");
        // 移除尾部的逗号
        String tableCreate = createCommand.substring(
          0, createCommand.length() - 1) + ");";
        System.out.println("Table Creation SQL for " +
          className + " is:\n" + tableCreate);
      }
    }
  }
  private static
  String getConstraints(Constraints con) {
    String constraints = "";
    if(!con.allowNull())
      constraints += " NOT NULL";
    if(con.primaryKey())
      constraints += " PRIMARY KEY";
    if(con.unique())
      constraints += " UNIQUE";
    return constraints;
  }
}
/* 输出:
Table Creation SQL for annotations.database.Member is:
CREATE TABLE MEMBER(
    FIRSTNAME VARCHAR(30));
Table Creation SQL for annotations.database.Member is:
CREATE TABLE MEMBER(
    FIRSTNAME VARCHAR(30),
    LASTNAME VARCHAR(50));
Table Creation SQL for annotations.database.Member is:
CREATE TABLE MEMBER(
    FIRSTNAME VARCHAR(30),
    LASTNAME VARCHAR(50),
    AGE INT);
Table Creation SQL for annotations.database.Member is:
CREATE TABLE MEMBER(
    FIRSTNAME VARCHAR(30),
    LASTNAME VARCHAR(50),
    AGE INT,
    REFERENCE VARCHAR(30) PRIMARY KEY);
*/

main()方法会遍历命令行中的所有类名,forName()方法负责加载所有类,而getAnnotation(DBTable.class)则检查类上是否有@DBTable注解。如果有,则会找到表名并保存下来。然后通过getDeclaredAnnotations()加载和校验类中所有的字段。该方法返回定义在某个方法上的所有注解。instanceof操作符用来确定这些注解是否是@SQLInteger@SQLString类型,不论是哪种,都会用表字段名来创建相关的String片段。注意,因为无法继承注解接口,所以使用getDeclaredAnnotations()是唯一一种能实现近似多态行为的方式。

内嵌的@Constraint注解会被传入getConstraints()方法,该方法用于创建包含SQL约束的字符串。

值得一提的是,用上述技巧来定义一套ORM是个略不成熟的方案。如果使用将表名作为参数的@DBTable类型,那么只要表名有变更,你就得重新编译Java代码,但你可能并不希望如此。有许多可用框架可以实现关系型数据库的ORM,并且越来越多的框架开始使用注解。

4.3 用javac处理注解

通过javac,你可以创建编译时注解处理器,并将注解应用于Java源文件,而不是编译后的类文件。不过这里有个重要的限制:无法通过注解处理器来修改源代码。唯一能影响结果的方法是创建新的文件。

如果注解处理器创建了一个新的源文件,则在新一轮处理中会检查该文件自身的注解。该工具会一轮接着一轮地持续处理,直到不再有新的源文件被创建,然后就编译所有的源文件。

你编写的每个注解都需要自己的处理器,但是javac可以轻松地将若干注解处理器进行组合。你可以指定多个要处理的类,并且还可以添加监听器来接收一轮处理完成的通知。

本节中的示例可带你入门,但是如果你需要深入了解,就要做好刻苦钻研的准备,多从Google和Stack Overflow上查找资料。

4.3.1 最简单的处理器

让我们从定义一个能想到的最简单的处理器(只是编译和测试一点东西)开始。下面是该注解的定义:

// annotations/simplest/Simple.java
// 一个非常简单的注解
package annotations.simplest;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;

@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),这里同样也只是为了演示。

以下是用来测试的示例:

// annotations/simplest/SimpleTest.java
// 测试“Simple”注解
// {java annotations.simplest.SimpleTest}
package annotations.simplest;

@Simple
public class SimpleTest {
  @Simple
  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 st = new SimpleTest();
    st.foo();
  }
}
/* 输出:
SimpleTest.foo()
*/

此处,我们用@Simple注解了所有@Target声明所允许的内容。

SimpleTest.java只要求Simple.java能成功编译,虽然编译的过程中什么都没有发生。javac允许使用@Simple注解(只要它还存在),但是并不会对它做任何事,直到我们创建了一个注解处理器,并将其绑定到编译器中。

以下示例是个非常简单的处理器,它所做的只是打印注解的信息:

// annotations/simplest/SimpleProcessor.java
// 一个非常简单的注解处理器
package annotations.simplest;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import java.util.*;

@SupportedAnnotationTypes(
  "annotations.simplest.Simple")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class SimpleProcessor
extends AbstractProcessor {
  @Override public boolean process(
    Set<? extends TypeElement> annotations,
    RoundEnvironment env) {
    for(TypeElement t : annotations)
      System.out.println(t);
    for(Element el :
      env.getElementsAnnotatedWith(Simple.class))
      display(el);
    return false;
  }
  private void display(Element el) {
    System.out.println("==== " + el + " ====");
    System.out.println(el.getKind() +
      " : " + el.getModifiers() +
      " : " + el.getSimpleName() +
      " : " + el.asType());
    if(el.getKind().equals(ElementKind.CLASS)) {
      TypeElement te = (TypeElement)el;
      System.out.println(te.getQualifiedName());
      System.out.println(te.getSuperclass());
      System.out.println(te.getEnclosedElements());
    }
    if(el.getKind().equals(ElementKind.METHOD)) {
      ExecutableElement ex = (ExecutableElement)el;
      System.out.print(ex.getReturnType() + " ");
      System.out.print(ex.getSimpleName() + "(");
      System.out.println(ex.getParameters() + ")");
    }
  }
}

已被废弃的旧apt(即Annotation Processing Tool,编译时注解处理器)版本的注解处理器需要额外的方法来确定哪些注解和Java版本可以被支持。而现在你可以简单地通过@SupportedAnnotationTypes@SupportedSourceVersion注解来达到相同的目的(这个示例很好地诠释了注解如何做到简化代码)。

此处唯一需要实现的方法是process(),其中包含了所有的逻辑。第一个参数会告诉你有哪些注解,第二个参数则包含余下的所有信息。此处我们做的只是把注解(只有一个)都打印了出来,要了解其他功能,请参考TypeElement文档。

通过process()方法的第二个参数,我们遍历所有被@Simple注解的元素,并且对每个元素都调用了display()方法。每个Element都可以携带自身的基本信息,例如getModifiers()能够告诉我们它是否是publicstatic的。

Element只能执行编译器解析过的所有基本对象共有的操作,而类和方法等则需要提取出额外的信息。因此(如果你能找到相关的文档,可能发现这是显而易见的,但是我能找到的所有文档都没提到这一点,因此我只能在Stack Overflow上寻找线索)你需要先检查它是哪种ElementKind,然后向下转型为更具体的元素类型——此处指针对CLASSTypeElement,以及针对METHODExecutableElement。然后,你就可以对这些Element类型调用额外的方法了。

动态向下转型(不会在编译期被检查)是一种“很不Java”的处理方式,因此看起来非常不直观,这也可能是我从来不想这么做的原因。相反,我花了好几天来研究应该如何读取信息,至少用已废弃的apt方式来实现都会多少更直观一些。目前为止,我仍然没有找到任何证据表明上述形式是规范,但在我看来它就是规范了。

如果你只是正常地编译SimpleTest.java,不会得到任何结果。想要得到注解的输出,就需要加上-processor标识和注解处理器类:

javac -processor annotations.simplest.SimpleProcessor SimpleTest.java

然后编译器会输出如下的结果:

annotations.simplest.Simple
==== annotations.simplest.SimpleTest ====
CLASS : [public] : SimpleTest : annotations.simplest.SimpleTest
annotations.simplest.SimpleTest
java.lang.Object
i,SimpleTest(),foo(),bar(java.lang.String,int,float),main(java.lang.String[])
==== i ====
FIELD : [] : 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)

这可以让你初步了解各种你日后可以探索的内容,包括参数名、类型、返回值等。

4.3.2 更复杂的处理器

当你创建了一个配合javac使用的注解处理器后,便无法使用Java的反射功能,因为此时操作的是源代码,而不是编译后的类。各种mirror(镜子)3可以解决该问题,方法是让你在未编译的源代码中查看方法、字段、类型。

3Java的设计者腼腆地提示,镜子就是指你发现反射的地方。

以下示例是一个注解,它从一个类中提取public方法,以将它们转换为接口:

// annotations/ifx/ExtractInterface.java
// 基于javac的注解处理
package annotations.ifx;
import java.lang.annotation.*;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface ExtractInterface {
  String interfaceName() default "-!!-";
}

其中RetentionPolicySOURCE,这是因为从类中提取接口后,就没有必要在类文件中继续保留该注解了。下面的测试类提供了一些可组成接口的public方法:

// annotations/ifx/Multiplier.java
// 基于javac的注解处理
// {java annotations.ifx.Multiplier}
package annotations.ifx;

@ExtractInterface(interfaceName="IMultiplier")
public class Multiplier {
  public boolean flag = false;
  private int n = 0;
  public int multiply(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 m = new Multiplier();
    System.out.println(
      "11 * 16 = " + m.multiply(11, 16));
  }
}
/* 输出:
11 * 16 = 176
*/

Multiplier类(只能用于正整型)中有个multiply()方法,它多次调用私有的add()方法,以实现相乘操作。add()方法不是public的,因此并不属于接口。其他方法则提供了一些语法的变体。该注解的interfaceName被赋值为IMultiplier,以作为要创建的接口名。

下面的示例是一个编译期处理器,它会提取出感兴趣的方法,并创建新接口的源代码文件(该源文件之后会作为“编译阶段”的一部分,被自动编译):

// annotations/ifx/IfaceExtractorProcessor.java
// 基于javac的注解处理
package annotations.ifx;
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.ifx.ExtractInterface")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class IfaceExtractorProcessor
extends AbstractProcessor {
  private ArrayList<Element>
    interfaceMethods = new ArrayList<>();
  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 + ")";
  }
}

Elements对象elementUtils是个static工具的集合,我们通过它来在writeInterfaceFile()中找到包名。

getEnclosedElements()方法生成被某个特定元素“围住”的所有元素。此处,该类围住了其所有的组件。通过getKind(),我们可以找到所有的publicstatic的方法,并将它们添加到interfaceMethods列表中。然后writeInterfaceFile()通过该列表来生成新的接口定义。注意在writeInterfaceFile()中对ExecutableElement的向下转型使得我们可以提取所有的方法信息。createArgList()则是一个生成参数列表的辅助方法。

Filer(由getFiler()生成)是一种创建新文件的PrintWriter。之所以使用Filer对象而非某个普通的PrintWriter,是因为Filer对象允许javac持续跟踪你创建的所有新文件,从而可以检查它们的注解,并在额外的“编译阶段”中编译它们。

如下代码是使用处理器来编译的命令行指令:

javac -processor annotations.ifx.IfaceExtractorProcessor Multiplier.java

它所生成的IMultiplier.java文件,看起来会像下面这样(通过上面的处理器中的println()语句,你也许能够猜到):

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

该文件同样也会被javac所编译(作为“编译阶段”的一部分),因此你可以在同一个目录中看到IMultiplier.class文件。

4.4 基于注解的单元测试

单元测试是一种常见做法——通过为类中的每个方法都创建一个或多个测试,以定期检测类中各部分的行为是否正确。目前在Java中最流行的单元测试工具是JUnit(参见基础卷第16章)。JUnit 4引入了注解。4在加入注解之前,JUnit的一个主要问题是需要大量的额外工作来设置和运行JUnit单元测试。随着时间的推移,这种情况已有所好转,但是注解则使测试更接近“你可能拥有的最简单的单元测试系统”这个目标。

4我原本想基于这里所述的设计思路实现一个“更好的JUnit”。然而,看起来JUnit 4也同样引入了很多相同的设计思路,因此还是跟随JUnit的原生版本比较简单。

在JUnit 4之前的版本中,你需要创建一个单独的类来持有你的单元测试代码。而在JUnit 4中,你可以将单元测试集成到要测试的类中,从而将各种耗时和故障降到最低值。这种方法还有额外的好处——它测试private方法就和测试public方法一样简单。

下面这个测试框架的示例是基于注解的,因此叫作@Unit。只用一个@Test注解来标识出需要测试的方法,是最基础也可能是你最常用的测试形式。也可以选择让测试方法不接收参数,仅返回一个boolean值来表示测试是成功还是失败。你可以给测试方法起任何你喜欢的名字。同样,@Unit注解的测试方法可以支持任何你想要的访问权限,包括private

要使用@Unit,你需要引入onjava.atunit,用@Unit注解标签标记出适当的方法和字段(你会在后面的示例中学到),然后让构建系统在结果类上运行@Unit。下面是个简单的示例:

// annotations/AtUnitExample1.java
// {java onjava.atunit.AtUnit
// build/classes/java/main/annotations/AtUnitExample1.class}
package annotations;
import onjava.atunit.*;
import onjava.*;

public class AtUnitExample1 {
  public String methodOne() {
    return "This is methodOne";
  }
  public int methodTwo() {
    System.out.println("This is methodTwo");
    return 2;
  }
  @Test
  boolean methodOneTest() {
    return methodOne().equals("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;
  }
}
/* 输出:
annotations.AtUnitExample1
  . anotherDisappointment (failed)
  . methodOneTest
  . failureTest (failed)
  . m2 This is methodTwo

  . m3
(5 tests)

>>> 2 FAILURES <<<
  annotations.AtUnitExample1: anotherDisappointment
  annotations.AtUnitExample1: failureTest
*/

要用@Unit测试的类必须放在包中。

methodOneTest()m2()m3()failureTest()anotherDisappointment()等方法前面的@Test注解告诉@Unit要将这些方法作为单元测试来运行,它同样也会确保这些方法不接收任何参数,并且返回值为booleanvoid。你编写单元测试时,只需要确定测试是成功还是失败,并分别返回true或者false(对于返回boolean值的方法)。

如果你对JUnit很熟悉,同样会注意到@Unit具有更丰富的信息输出。你可以看到正在运行的测试,因此测试产生的输出会更有用,并且最后它会告诉你导致失败的类和测试用例。

如果将测试方法嵌入类中对你来说并不适用,那么你就无须这么做。要创建非嵌入式的测试,最简单的方法是使用继承:

// annotations/AUExternalTest.java
// 创建非嵌入的测试
// {java onjava.atunit.AtUnit
// build/classes/java/main/annotations/AUExternalTest.class}
package annotations;
import onjava.atunit.*;
import onjava.*;

public class
AUExternalTest extends AtUnitExample1 {
  @Test
  boolean tMethodOne() {
    return methodOne().equals("This is methodOne");
  }
  @Test
  boolean tMethodTwo() {
    return methodTwo() == 2;
  }
}
/* 输出:
annotations.AUExternalTest
  . tMethodOne
  . tMethodTwo This is methodTwo

OK (2 tests)
*/

以上示例同样展示了灵活命名的好处。这里,直接用于测试某方法的@Test方法以该方法的方法名前面加“t”来命名(我并不是推荐这种命名方式,只是举了个可能的例子)。

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

// annotations/AUComposition.java
// 创建非嵌入的测试
// {java onjava.atunit.AtUnit
// build/classes/java/main/annotations/AUComposition.class}
package annotations;
import onjava.atunit.*;
import onjava.*;

public class AUComposition {
  AtUnitExample1 testObject = new AtUnitExample1();
  @Test
  boolean tMethodOne() {
    return testObject.methodOne()
      .equals("This is methodOne");
  }
  @Test
  boolean tMethodTwo() {
    return testObject.methodTwo() == 2;
  }
}
/* 输出:
annotations.AUComposition
  . tMethodOne
  . tMethodTwo This is methodTwo

OK (2 tests)
*/

这里给每个测试都创建了一个AUComposition对象,因此也为每个测试都创建了一个新的testObject成员。

和JUnit不同,这里并没有专门的“assert”(断言)方法,而是使用了@Test方法的第二种形式,返回了void(或者boolean,如果你仍然希望在这里返回truefalse)。如果要验证成功,你可以使用Java的断言语句。Java断言一般在java命令行指令中由-ea标签来启用,但是@Unit会自动启用断言。如果要表示失败,你甚至可以使用异常。@Unit的设计目标之一是尽可能不增加语法复杂度,而Java断言和异常则是报告错误所必需的。如果测试方法引发了失败断言或者异常,则会被视为测试失败,但是@Unit并不会因此阻塞——它会持续运行,直到所有的测试都运行完毕。如下例所示:

// annotations/AtUnitExample2.java
// 断言和异常可以在@Tests中使用
// {java onjava.atunit.AtUnit
// build/classes/java/main/annotations/AtUnitExample2.class}
package annotations;
import java.io.*;
import onjava.atunit.*;
import onjava.*;

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 fis =
        new FileInputStream("nofile.txt")) {} // 抛出
  }
  @Test
  boolean assertAndReturn() {
    // 附带消息的断言:
    assert methodTwo() == 2: "methodTwo must equal 2";
    return methodOne().equals("This is methodOne");
  }
}
/* 输出:
annotations.AtUnitExample2
  . assertFailureExample java.lang.AssertionError: What
a surprise!
(failed)
  . assertExample
  . exceptionExample java.io.FileNotFoundException:
nofile.txt (The system cannot find the file specified)
(failed)
  . assertAndReturn This is methodTwo

(4 tests)

>>> 2 FAILURES <<<
  annotations.AtUnitExample2: assertFailureExample
  annotations.AtUnitExample2: exceptionExample
*/

下面是用断言实现的非嵌入式测试,它对java.util.HashSet做了一些简单的测试:

// annotations/HashSetTest.java
// {java onjava.atunit.AtUnit
// build/classes/java/main/annotations/HashSetTest.class}
package annotations;
import java.util.*;
import onjava.atunit.*;
import onjava.*;

public class HashSetTest {
  HashSet<String> testObject = new HashSet<>();
  @Test
  void initialization() {
    assert testObject.isEmpty();
  }
  @Test
  void tContains() {
    testObject.add("one");
    assert testObject.contains("one");
  }
  @Test
  void tRemove() {
    testObject.add("one");
    testObject.remove("one");
    assert testObject.isEmpty();
  }
}
/* 输出:
annotations.HashSetTest
  . tContains
  . initialization
  . tRemove
OK (3 tests)
*/

在没有其他约束的情况下,继承的方式看起来似乎更简单。

在每个单元测试中,@Unit都通过无参数的构造方法,为每个要测试的类创建了一个对象。测试会在该对象上进行,然后该对象会被丢弃,以防止各种副作用渗透到其他单元测试中。这里依赖无参数的构造方法来创建对象。如果没有无参数的构造函数,或者需要更复杂的构造函数,你需要创建一个静态方法来构建对象,并添加@TestObjectCreate注解,就像下面这样:

// annotations/AtUnitExample3.java
// {java onjava.atunit.AtUnit
// build/classes/java/main/annotations/AtUnitExample3.class}
package annotations;
import onjava.atunit.*;
import onjava.*;

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.println("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; }
}
/* 输出:
annotations.AtUnitExample3
  . initialization
  . methodOneTest
  . m2 This is methodTwo

OK (3 tests)
*/

@TestObjectCreate方法必须是静态的,并且必须返回你测试的类型的对象。@Unit程序会确保这些。

有时你需要额外的字段来支持单元测试。@TestProperty注解可以标识仅用于单元测试的字段(这样在交付给客户前便可以随意移除这些字段)。下面是一个示例,它读取一个被String.split()方法切割后的字符串的值,该值会作为输入来生成测试对象:

// annotations/AtUnitExample4.java
// {java onjava.atunit.AtUnit
// build/classes/java/main/annotations/AtUnitExample4.class}
// {VisuallyInspectOutput}
package annotations;
import java.util.*;
import onjava.atunit.*;
import onjava.*;

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 rand = new Random(); // 基于时间因素的随机种子
  public AtUnitExample4(String word) {
    this.word = word;
  }
  public String getWord() { return word; }
  public String scrambleWord() {
    List<Character> chars = Arrays.asList(
      ConvertTo.boxed(word.toCharArray()));
    Collections.shuffle(chars, rand);
    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());
    else
      return null;
  }
  @Test
  boolean words() {
    System.out.println("'" + getWord() + "'");
    return getWord().equals("are");
  }
  @Test
  boolean scramble1() {
    // 用指定的种子得到可验证的结果:
    rand = new Random(47);
    System.out.println("'" + getWord() + "'");
    String scrambled = scrambleWord();
    System.out.println(scrambled);
    return scrambled.equals("lAl");
  }
  @Test
  boolean scramble2() {
    rand = new Random(74);
    System.out.println("'" + getWord() + "'");
    String scrambled = scrambleWord();
    System.out.println(scrambled);
    return scrambled.equals("tsaeborornussu");
  }
}
/* 输出:
annotations.AtUnitExample4
  . words 'All'
(failed)
  . scramble1 'brontosauruses'
ntsaueorosurbs
(failed)
  . scramble2 'are'
are
(failed)
(3 tests)

>>> 3 FAILURES <<<
  annotations.AtUnitExample4: words
  annotations.AtUnitExample4: scramble1
  annotations.AtUnitExample4: scramble2
*/

@TestProperty同样可以用来标识在测试期间可用,但自身并不是测试的方法。

这个程序依赖于测试执行的顺序,通常来说这并不是一种好的实现方式。

如果测试对象的创建过程需要执行初始化,而且需要在稍后清理对象,你可以选择添加一个静态的@TestObjectCleanup方法,以在使用完测试对象后执行清理工作。在下一个示例中,@TestObjectCreate通过打开一个文件来创建各个测试对象,因此必须在丢弃测试对象前关闭该文件。

// annotations/AtUnitExample5.java
// {java onjava.atunit.AtUnit
// build/classes/java/main/annotations/AtUnitExample5.class}
package annotations;
import java.io.*;
import onjava.atunit.*;
import onjava.*;

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;
  }
}
/* 输出:
annotations.AtUnitExample5
  . test1
Running cleanup
  . test3
Running cleanup
  . test2
Running cleanup
OK (3 tests)
*/

从以上输出可以看出,在每项测试之后,清理方法都被自动执行了。

4.4.1 在@Unit中使用泛型

泛型会带来一个特别的问题,因为你无法“笼统地测试”,而只能对特定的类型参数或参数集合进行测试。解决这个问题的办法很简单:从泛型类的某个具体版本继承一个测试类。

下面的示例简单地实现了一个栈:

// annotations/StackL.java
// 用LinkedList构建的栈
package annotations;
import java.util.*;

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(); }
}

如果要测试字符串的版本,则从StackL<String>继承一个测试类:

// annotations/StackLStringTst.java
// 将@Unit应用于泛型
// {java onjava.atunit.AtUnit
// build/classes/java/main/annotations/StackLStringTst.class}
package annotations;
import onjava.atunit.*;
import onjava.*;

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");
  }
}
/* 输出:
annotations.StackLStringTst
  . tPop
  . tTop
  . tPush
OK (3 tests)
*/

继承的唯一潜在缺点是,你会失去访问被测试类中private方法的能力。如果你不希望这样,则可以将该方法设为protected,或者添加一个非私有的@TestProperty方法来调用该私有方法(本章稍后介绍的AtUnitRemover工具会从产品代码中剥离@TestProperty方法)。

@Unit会查找包含适当注解的类文件,然后执行@Test方法。对于@Unit测试系统,我的主要目标是使它极度透明,仅需要添加@Test方法即可上手使用,而不需要其他的特殊代码或知识(现代版本的JUnit遵从了这个思路)。要想不添加任何新的障碍就实现测试的编写是基本不可能的,因此@Unit会尽力使过程变得更轻松。只有这样,你才会更愿意编写测试。

4.4.2 实现@Unit

首先,我们来定义所有的注解类型。这些都是很简单的标签,并没有任何字段。在本章开始已经介绍了@Test标签的定义,接下来是其余的注解:

// onjava/atunit/TestObjectCreate.java
// @Unit @TestObjectCreate标签
package onjava.atunit;
import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TestObjectCreate {}
// onjava/atunit/TestObjectCleanup.java
// @Unit @TestObjectCleanup标签
package onjava.atunit;
import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TestObjectCleanup {}
// onjava/atunit/TestProperty.java
// @Unit @TestProperty标签
package onjava.atunit;
import java.lang.annotation.*;

// 字段(Field)和方法(Method)都可以被标记为属性(property):
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface TestProperty {}

所有测试的生命周期都限定在RUNTIME内,这是因为@Unit系统必须在编译后的代码中检测到这些测试。

为了实现运行这些测试的系统,我们利用反射来提取注解。程序会利用这些信息来决定如何构建测试对象,并在其上运行测试。注解使得结果出乎意料地简单易懂:

// onjava/atunit/AtUnit.java
// 一个基于注解的单元测试框架
// {java onjava.atunit.AtUnit}
package onjava.atunit;
import java.lang.reflect.*;
import java.io.*;
import java.util.*;
import java.nio.file.*;
import java.util.stream.*;
import onjava.*;

public class AtUnit implements ProcessFiles.Strategy {
  static Class<?> testClass;
  static List<String> failedTests= new ArrayList<>();
  static long testsRun = 0;
  static long failures = 0;
  public static void
  main(String[] args) throws Exception {
    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 cFile) {
    try {
      String cName = ClassNameFinder.thisClass(
        Files.readAllBytes(cFile.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 m : testClass.getDeclaredMethods()) {
      testMethods.addIfTestMethod(m);
      if(creator == null)
        creator = checkForCreatorMethod(m);
      if(cleanup == null)
        cleanup = checkForCleanupMethod(m);
    }
    if(testMethods.size() > 0) {
      if(creator == null)
        try {
          if(!Modifier.isPublic(testClass
             .getDeclaredConstructor()
             .getModifiers())) {
            System.out.println("Error: " + testClass +
              " zero-argument constructor must be public");
            System.exit(1);
          }
        } catch(NoSuchMethodException e) {
          // 同步的无参构造器,没有问题
        }
      System.out.println(testClass.getName());
    }
    for(Method m : testMethods) {
      System.out.print("  . " + m.getName() + " ");
      try {
        Object testObject = createTestObject(creator);
        boolean success = false;
        try {
          if(m.getReturnType().equals(boolean.class))
            success = (Boolean)m.invoke(testObject);
          else {
            m.invoke(testObject);
            success = true; // 如果没有断言失败
          }
        } catch(InvocationTargetException e) {
          // 实际的异常在e中:
          System.out.println(e.getCause());
        }
        System.out.println(success ? "" : "(failed)");
        testsRun++;
        if(!success) {
          failures++;
          failedTests.add(testClass.getName() +
            ": " + m.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 m) {
      if(m.getAnnotation(Test.class) == null)
        return;
      if(!(m.getReturnType().equals(boolean.class) ||
          m.getReturnType().equals(void.class)))
        throw new RuntimeException("@Test method" +
          " must return boolean or void");
      m.setAccessible(true); // 如果是private的,等等
      add(m);
    }
  }
  private static
  Method checkForCreatorMethod(Method m) {
    if(m.getAnnotation(TestObjectCreate.class) == null)
      return null;
    if(!m.getReturnType().equals(testClass))
      throw new RuntimeException("@TestObjectCreate " +
        "must return instance of Class to be tested");
    if((m.getModifiers() &
         java.lang.reflect.Modifier.STATIC) < 1)
      throw new RuntimeException("@TestObjectCreate " +
        "must be static.");
    m.setAccessible(true);
    return m;
  }
  private static
  Method checkForCleanupMethod(Method m) {
    if(m.getAnnotation(TestObjectCleanup.class) == null)
      return null;
    if(!m.getReturnType().equals(void.class))
      throw new RuntimeException("@TestObjectCleanup " +
        "must return void");
    if((m.getModifiers() &
        java.lang.reflect.Modifier.STATIC) < 1)
      throw new RuntimeException("@TestObjectCleanup " +
        "must be static.");
    if(m.getParameterTypes().length == 0 ||
       m.getParameterTypes()[0] != testClass)
      throw new RuntimeException("@TestObjectCleanup " +
        "must take an argument of the tested type.");
    m.setAccessible(true);
    return m;
  }
  private static Object
  createTestObject(Method creator) {
    if(creator != null) {
      try {
        return creator.invoke(testClass);
      } catch(IllegalAccessException |
              IllegalArgumentException |
              InvocationTargetException e) {
        throw new RuntimeException("Couldn't run " +
          "@TestObject (creator) method.");
      }
    } else { // 使用无参数的构造器:
      try {
        return testClass
          .getConstructor().newInstance();
      } catch(InstantiationException |
              NoSuchMethodException |
              InvocationTargetException |
              IllegalAccessException e) {
        throw new RuntimeException(
          "Couldn't create a test object. " +
          "Try using a @TestObject method.");
      }
    }
  }
}

尽管这可能算是“过早重构”(因为本书中只用过一次),AtUnit.java使用了另一个叫作ProcessFiles的工具来单步遍历命令行中的每个参数,以及确定它是目录还是文件,并进行相应的处理。其中包含了一个可定制的Strategy(策略)接口,因此可应用于多种方案实现。

// onjava/ProcessFiles.java
package onjava;
import java.io.*;
import java.nio.file.*;

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);
    }
  }
  public void
  processDirectoryTree(File root) throws IOException {
    PathMatcher matcher = FileSystems.getDefault()
      .getPathMatcher("glob:**/*.{" + ext + "}");
    Files.walk(root.toPath())
      .filter(matcher::matches)
      .forEach(p -> strategy.process(p.toFile()));
  }
}

AtUnit类实现了ProcessFiles.Strategy,其中包含process()方法。由此,AtUnit的实例可以传递给ProcessFiles构造器。构造器的第二个参数告诉ProcessFiles去查找所有文件名后缀为.class的文件。

以下是简单的用法示例:

// annotations/DemoProcessFiles.java
import onjava.ProcessFiles;

public class DemoProcessFiles {
  public static void main(String[] args) {
    new ProcessFiles(file -> System.out.println(file),
      "java").start(args);
  }
}
/* 输出:
.\AtUnitExample1.java
.\AtUnitExample2.java
.\AtUnitExample3.java
.\AtUnitExample4.java
.\AtUnitExample5.java
.\AUComposition.java
.\AUExternalTest.java
.\database\Constraints.java
.\database\DBTable.java
.\database\Member.java
.\database\SQLInteger.java
.\database\SQLString.java
.\database\TableCreator.java
.\database\Uniqueness.java
.\DemoProcessFiles.java
.\HashSetTest.java
.\ifx\ExtractInterface.java
.\ifx\IfaceExtractorProcessor.java
.\ifx\Multiplier.java
.\PasswordUtils.java
.\simplest\Simple.java
.\simplest\SimpleProcessor.java
.\simplest\SimpleTest.java
.\SimulatingNull.java
.\StackL.java
.\StackLStringTst.java
.\Testable.java
.\UseCase.java
.\UseCaseTracker.java
*/

在没有命令行参数的情况下,程序会遍历当前的目录树。你还可以提供多个参数,可以是类文件(不论文件名是否带有.class后缀)或目录。

回到我们关于AtUnit.java的讨论,@Unit会自动找到可测试的类和方法,因此并不需要“套件”机制。

AtUnit.java在寻找类文件时有个必须解决的问题:从类文件名无法确切地得知限定的类名(包括包名)。要获取这个信息,就必须分析类文件。这并非易事,但也并非做不到。5当找到一个.class文件时,程序会打开该文件,读取它的二进制数据,并传给ClassNameFinder.thisClass()。此处,我们将进入“字节码工程”的领域,因为我们实际上已经在分析类文件的内容了。

5我和Jeremy Meyer一起花了几乎一整天才搞清楚这件事。

// onjava/atunit/ClassNameFinder.java
// {java onjava.atunit.ClassNameFinder}
package onjava.atunit;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import onjava.*;

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));
      int magic = data.readInt();  // 0xcafebabe
      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();
        // int tableSize;
        switch(tag) {
          case 1: // UTF
            int length = data.readShort();
            char[] bytes = new char[length];
            for(int k = 0; k < bytes.length; k++)
              bytes[k] = (char)data.read();
            String className = new String(bytes);
            classNameTable.put(i, className);
            break;
          case 5: // LONG
          case 6: // DOUBLE
            data.readLong(); // 丢弃8字节
            i++; // 必要的特殊处理,跳过此处
            break;
          case 7: // CLASS
            int offset = data.readShort();
            offsetTable.put(i, offset);
            break;
          case 8: // STRING
            data.readShort(); // 抛弃2字节
            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(); // 抛弃4字节
            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:"))
      // .filter(s -> s.indexOf('$') >= 0)
      .map(s -> s.split(":")[1])
      .filter(s -> !s.startsWith("enums."))
      .filter(s -> s.contains("."))
      .forEach(System.out::println);
  }
}
/* 输出:
onjava.ArrayShow
onjava.atunit.AtUnit$TestMethods
onjava.atunit.AtUnit
onjava.atunit.ClassNameFinder
onjava.atunit.Test
onjava.atunit.TestObjectCleanup
onjava.atunit.TestObjectCreate
onjava.atunit.TestProperty
onjava.BasicSupplier
onjava.CollectionMethodDifferences
onjava.ConvertTo
onjava.Count$Boolean
onjava.Count$Byte
onjava.Count$Character
onjava.Count$Double
onjava.Count$Float
onjava.Count$Integer
onjava.Count$Long
onjava.Count$Pboolean
onjava.Count$Pbyte
onjava.Count$Pchar
onjava.Count$Pdouble
onjava.Count$Pfloat
onjava.Count$Pint
onjava.Count$Plong
onjava.Count$Pshort
onjava.Count$Short
onjava.Count
onjava.CountingIntegerList
onjava.CountMap
onjava.Countries
onjava.Enums
onjava.FillMap
onjava.HTMLColors
onjava.MouseClick
onjava.Nap
onjava.Null
onjava.Operations
onjava.OSExecute
onjava.OSExecuteException
onjava.Pair
onjava.ProcessFiles$Strategy
onjava.ProcessFiles
onjava.Rand$Boolean
onjava.Rand$Byte
onjava.Rand$Character
onjava.Rand$Double
onjava.Rand$Float
onjava.Rand$Integer
onjava.Rand$Long
onjava.Rand$Pboolean
onjava.Rand$Pbyte
onjava.Rand$Pchar
onjava.Rand$Pdouble
onjava.Rand$Pfloat
onjava.Rand$Pint
onjava.Rand$Plong
onjava.Rand$Pshort
onjava.Rand$Short
onjava.Rand$String
onjava.Rand
onjava.Range
onjava.Repeat
onjava.RmDir
onjava.Sets
onjava.Stack
onjava.Suppliers
onjava.TimedAbort
onjava.Timer
onjava.Tuple
onjava.Tuple2
onjava.Tuple3
onjava.Tuple4
onjava.Tuple5
onjava.TypeCounter
*/

虽然这里不可能深入挖掘所有细节,但每个类文件都已遵从了一种特定的格式,我也已经尽量使用了有意义的字段名来表示从ByteArrayInputStream中取出的数据片段。还可以通过对输入流执行的读取的长度,来得知每个数据片段的大小。举例来说,任何类文件的头32位永远是十六进制的“魔术数字”:0xcafebabe,6并且之后的两个短整型位是版本信息。常量池保存了程序所需的常量,因此大小不固定。接下来的短整型则告知了常量池的大小,由此可以分配一个大小合适的数组。常量池中的每个项都可以是固定长度或长度可变的值,因此我们必须检查每个项开头的标签,由此来决定该如何处理它,即例子中的switch语句。此处,我们并不去试图精确地分析类文件中的所有数据,而仅仅是逐步遍历数据,然后保存其中感兴趣的部分,因此你会发现大量的数据被丢弃了。类的信息被保存在classNameTableoffsetTable中,在读取完常量池后,程序会找到thisClass的信息,它是offsetTable的索引,offsetTable则生成classNameTable的索引,而classNameTable则生成类名。

6关于该魔术数字的意义,衍生了很多版本的传说。

回到AtUnit.java,process()方法现在得到了类名,我们进而可以查看它是否包含.,这代表它是否在包中,不在包中的类会被忽略。如果类在包中,则会由标准的类加载器通过Class.forName()方法来加载该类。现在可以来分析该类中的@Unit注解了。

我们只需要找到三样东西:@Test方法(保存在TestMethods列表中)、@TestObjectCreate方法和@TestObjectCleanup方法。正如你在代码中所见,这几样东西是通过调用相关方法(用于查找注解)找到的。

如果找到了任何@Test方法,便会显示出类名,由此可以看到当前正在发生些什么,接下来便会执行各项测试。这意味着会打印方法名,然后调用createTestObject()方法,后者会使用@TestObjectCreate方法(如果该方法存在;否则会回退到无参数的构造器)。一旦创建了测试对象,便会对该对象执行测试方法。如果测试返回boolean,结果便会被捕获;如果没有,且没有抛出异常(异常会在断言失败或任何其他异常发生时抛出),我们便认为测试成功了。如果抛出了异常,便会打印出异常信息以告知细节。如果发生任何失败,失败数会累加,并且类名和方法名会被追加到failedTests,由此可以在测试结束后报告所有错误信息。

4.5 总结

注解是一个很受欢迎的Java新特性。它是一种结构化且接受类型检查的向代码中添加元数据的方法,并且不会导致代码被渲染得混乱和不可读。它可以帮助我们免除部署描述文件和其他生成文件的编写工作。Javadoc中的@deprecated标签被@Deprecated注解所取代,仅这一点便印证了由合适的注解来描述代码组件的信息优于用注释来做同样的事。

Java中仅有少量的注解,这意味着如果你没有在别处找到相关的库,便需要自行创建注解以及相关逻辑。利用javac附带的注解处理器,只需一步就可以编译新创建的文件,以简化构建过程。

各种API和框架的开发者将逐渐引入注解,使其成为工具包的一部分。通过@Unit系统,你可以想象,注解很可能会给Java的编程体验带来巨大的改变。