Post

《Java核心技术》笔记 卷II 第8章 脚本、编译和注解处理

《Java核心技术》笔记 卷II 第8章 脚本、编译和注解处理

本章将介绍三种用于处理代码的技术:脚本API使你可以调用诸如JavaScript和Groovy这样的脚本语言代码;当你希望在应用程序内部编译Java代码时,可以使用编译器API;注解处理器可以操作包含注解的Java源代码和类文件。

8.1 Java平台的脚本

脚本语言是一种通过在运行时解释程序文本,从而避免通常的编辑/编译/链接/运行循环的语言。脚本语言有许多优势:快速反馈、鼓励实验,可以改变正在运行的程序的行为,允许用户自定义。另一方面,大多数脚本语言缺乏对编写复杂应用有益的特性,例如强类型、封装和模块化。

因此,将脚本语言和传统语言的优势相结合是很有诱惑力的。脚本API使你可以在Java平台上实现这一点。它能够在Java程序中调用使用JavaScript、Groovy、Ruby,甚至更加奇异的Scheme和Haskell等语言编写的脚本。

8.1.1 获取脚本引擎

脚本引擎是可以执行用特定语言编写的脚本的库。当虚拟机启动时,它会发现可用的脚本引擎。为了枚举这些引擎,构造一个ScriptEngineManager并调用getEngineFactories()方法。可以查询每个引擎工厂支持的引擎名、MIME类型和文件扩展名。

引擎名称MIME类型扩展名
Rhino (JavaScript)rhino, Rhino,
JavaScript, javascript
application/javascript,
application/ecmascript,
text/javascript,
text/ecmascript
js
Groovygroovygroovy
RenjinRenjintext/x-RR, r, S, s

可以通过名称、MIME类型或扩展名获得脚本引擎。例如:

1
2
var manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("javascript");

需要在类路径中提供实现脚本引擎的JAR文件。(Oracle JDK曾经包含一个JavaScript引擎(Nashorn),但是在Java 15中被移除了。)

8.1.2 脚本计算和绑定

一旦有了引擎,就可以通过以下方法来调用脚本:

1
Object result = engine.eval(scriptString);

如果脚本存储在文件中,也可以提供一个Reader

可以在同一个引擎上多次调用脚本。如果脚本定义了变量、函数或类,大多数引擎都会保留这些定义供将来使用。例如:

1
2
engine.eval("n = 1728");
Object result = engine.eval("n + 1"); // 1729

注释:要确定在多个线程中并发执行脚本是否安全,调用

1
Object param = factory.getParameter("THREADING");

返回值是下列之一:

  • null:并发执行不安全。
  • "MULTITHREADED":并发执行安全。一个线程的执行效果对另一个线程可能是可见的。
  • "THREAD-ISOLATED":除了满足"MULTITHREADED",还会为每个线程维护不同的变量绑定。
  • "STATELESS":除了满足"THREAD-ISOLATED",脚本不会修改变量绑定。

可以向引擎添加变量绑定。绑定由名字和关联的Java对象构成。例如:

1
2
engine.put("k", 1728);
Object result = engine.eval("k + 1");

脚本代码从“引擎作用域”的绑定中读取k的定义。

大多数脚本语言都可以访问Java对象,通常比Java语法更简单。例如:

1
2
engine.put("b", new JButton());
engine.eval("b.text = 'Ok'");

反过来,也可以获取由脚本语句绑定的变量:

1
2
engine.eval("n = 1728");
Object result = engine.get("n");

除了引擎作用域,还有全局作用域。任何添加到ScriptEngineManager的绑定对所有引擎都是可见的。另外,还可以将绑定收集到一个Bindings类型的对象中,并将其传递给eval()方法:

1
2
3
Bindings scope = engine.createBindings();
scope.put("b", new JButton());
engine.eval(scriptString, scope);

注释:如果需要除了引擎和全局作用域之外的其他作用域(例如Web容器可能需要请求和会话作用域),需要写一个实现ScriptContext接口的类。

8.1.3 重定向输入和输出

可以通过调用脚本上下文对象的setReader()setWriter()方法来重定向脚本的标准输入和输出。例如

1
2
var writer = new StringWriter();
engine.getContext().setWriter(new PrintWriter(writer, true));

这两个方法只会影响脚本引擎的标准输入和输出。例如,如果执行下面的JavaScript代码:

1
2
println("Hello");
java.lang.System.out.println("World");

只有第一个输出会被重定向。

Rhino引擎没有标准输入源的概念,因此调用setReader()没有任何效果。

8.1.4 调用脚本函数和方法

许多脚本引擎可以在不计算脚本代码的情况下调用脚本语言的函数。提供这种功能的脚本引擎(如Rhino)实现了Invocable接口。

要调用一个函数,需要调用invokeFunction()方法并指定函数名和函数参数:

1
2
3
4
5
// Define greet function in JavaScript
engine.eval("function greet(how, whom) { return how + ', ' + whom + '!' }");

// Call the function with arguments "Hello", "World"
result = ((Invocable) engine).invokeFunction("greet", "Hello", "World"); // "Hello, World!"

如果脚本语言是面向对象的,可以调用invokeMethod()

1
2
3
4
5
6
7
8
9
10
// Define Greeter class in JavaScript
engine.eval("function Greeter(how) { this.how = how }");
engine.eval("Greeter.prototype.welcome = "
    + " function(whom) { return this.how + ', ' + whom + '!' }");
  
// Construct an instance
Object yo = engine.eval("new Greeter('Yo')");

// Call the welcome method on the instance
result = ((Invocable) engine).invokeMethod(yo, "welcome", "World"); // "Yo, World!"

注释:Rhino不支持现代JavaScript类语法。

注释:即使脚本引擎没有实现Invocable接口,仍然可以用语言无关的方式调用方法。ScriptEngineFactory接口的getMethodCallSyntax()方法产生一个可以传递给eval()方法的字符串。但是,所有的方法参数都必须与名字绑定。(例如,factory.getMethodCallSyntax("yo", "welcome", "whom")返回"yo.welcome(whom);"

可以更进一步,让脚本引擎实现一个Java接口。然后就可以用Java语法来调用脚本函数和方法。

细节取决于脚本引擎,但一般需要为接口中的每个方法提供一个函数。例如,考虑下面的Java接口:

1
2
3
public interface Greeter {
    String welcome(String whom);
}

如果在Rhino中定义了具有相同名字的全局函数,就可以通过这个接口调用它:

1
2
3
4
5
6
// Define welcome function in JavaScript
engine.eval("function welcome(whom) { return 'Hello, ' + whom + '!' }");

// Get a Java object and call a Java method
Greeter g = ((Invocable) engine).getInterface(Greeter.class);
result = g.welcome("World"); // "Hello, World!"

在面向对象的脚本语言中,可以通过匹配的Java接口来访问一个脚本类。例如,可以像这样用Java语法来调用JavaScript Greeter类的对象:

1
2
Greeter g = ((Invocable) engine).getInterface(yo, Greeter.class);
result = g.welcome("World"); // "Yo, World!"

8.1.5 编译脚本

某些脚本引擎可以将脚本代码编译成中间格式,以便高效执行。这些引擎实现了Compilable接口。下面的示例展示了如何编译并计算包含在脚本文件中的代码:

1
2
3
4
var reader = new FileReader("myscript.js");
CompiledScript script = null;
if (engine instanceof Compilable)
    script = ((Compilable) engine).compile(reader);

一旦脚本被编译,就可以执行它。

1
2
3
4
if (script != null)
    script.eval();
else
    engine.eval(reader);

当然,只有需要重复执行时,编译脚本才有意义。

8.1.6 示例:用脚本处理GUI事件

为了演示脚本API,本节将编写一个示例程序,允许用户指定用自己选择的脚本语言编写的GUI事件处理器。

程序清单8-1中的程序可以向任意窗体类添加脚本。默认情况下,它会读取程序清单8-2中的ButtonFrame类,该类与卷I第10章中的(程序清单10-5)类似,但是有两个差异:

  • 每个组件都设置了name属性。
  • 没有事件处理器。

事件处理器是在属性文件中定义的,每个属性定义具有以下形式:

1
componentName.eventName = scriptCode

例如,如果选择使用JavaScript,就在js.properties文件中提供事件处理器:

1
2
3
yellowButton.action=panel.background = java.awt.Color.YELLOW
blueButton.action=panel.background = java.awt.Color.BLUE
redButton.action=panel.background = java.awt.Color.RED

示例代码还包括用于Groovy和R的属性文件。

该程序首先加载指定脚本语言的引擎(默认为JavaScript)。然后处理init.language脚本(如果存在),用于像R这样需要初始化的语言。接下来,递归遍历所有子组件,并将(name, object)绑定添加到一个映射,然后将绑定添加到引擎。最后,读取language.properties文件。对于每个属性,合成一个事件处理器代理,来执行脚本代码(参见卷I第6章 6.5节)。关键在于每个事件处理器都调用了engine.eval()

程序清单8-1 script/ScriptTest.java

程序清单8-2 buttons1/ButtonFrame.java

如果使用Java 15或以上,类路径必须包含一个像Rhino (https://rhino.github.io/)这样的JavaScript引擎。像这样运行该程序:

1
java -classpath .:rhino-1.7.14.jar:rhino-engine-1.7.14.jar script.ScriptTest

对于Groovy (https://www.groovy-lang.org/),使用

1
java -classpath .:$groovy/lib/\* script.ScriptTest groovy

其中$groovy是Groovy的安装目录。

对于R的Renjin实现,需要在类路径中包含Renjin Studio和脚本引擎的JAR文件,可以在 https://www.renjin.org/downloads.html 下载。

8.2 编译器API

有很多工具都需要编译Java代码,例如集成开发环境和Java Server Pages(JSP,嵌入了Java代码的网页)。

8.2.1 调用编译器

调用编译器非常简单,下面是一个示例:

1
2
3
4
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
OutputStream outStream = ...;
OutputStream errStream = ...;
int result = compiler.run(null, outStream, errStream, "-sourcepath", "src", "Test.java");

返回值为0表示编译成功。

编译器会将输出和错误消息发送到提供的流,如果为null则使用System.outSystem.err。第一个参数是输入流,编译器不接受控制台输入,因此始终为nullrun()方法继承自Tool接口,它允许工具读取输入)。run()方法的其余参数(选项或文件名)将传递给javac

8.2.2 发起编译任务

可以使用CompilationTask对象对编译过程进行更多控制。例如,从字符串提供源码,在内存中捕获类文件,或者处理错误和警告消息。

可以像这样获得CompilationTask对象:

1
2
3
4
5
6
7
JavaCompiler.CompilationTask task = compiler.getTask(
    errorWriter, // Uses System.err if null
    fileManager, // Uses the standard file manager if null
    diagnostics, // Uses System.err if null
    options, // null if no options
    classes, // For annotation processing; null if none
    sources);

例如:

1
2
3
4
5
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
Iterable<String> fileNames = List.of("File1.java", "File2.java");
Iterable<JavaFileObject> sources = fileManager.getJavaFileObjectsFromStrings(fileNames);
Iterable<String> options = List.of("-d", "bin");
JavaCompiler.CompilationTask task = compiler.getTask(null, null, null, options, null, sources);

注释:classes参数只用于注解处理。在这种情况下,还需要调用task.processors()。注解处理的示例参见8.6节。

getTask()方法返回任务对象,但是并不启动编译过程。CompilationTask类扩展了Callable<Boolean>,因此可以将其传递给ExecutorService以并行执行,也可以只进行异步调用:

1
Boolean success = task.call();

8.2.3 捕获诊断信息

为了监听错误消息,需要安装一个DiagnosticListener。监听器在编译器报告警告或错误消息时会接收到一个Diagnostic对象。DiagnosticCollector类实现了该接口,它会收集所有诊断信息,可以在编译完成之后遍历这些信息。

1
2
3
4
5
DiagnosticCollector<JavaFileObject> collector = new DiagnosticCollector<>();
compiler.getTask(null, fileManager, collector, null, null, sources).call();
for (Diagnostic<? extends JavaFileObject> d : collector.getDiagnostics()) {
    System.out.println(d);
}

Diagnostic对象包含有关问题位置的信息(包括文件名、行号和列号),以及人类可读的描述。

还可以在标准文件管理器上安装诊断监听器,以便捕获有关缺失文件的消息:

1
StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null);

8.2.4 从内存中读取源文件

如果动态生成源代码,就可以从内存中编译,而无需将文件保存到磁盘。例如,使用下面的类持有源代码:

compiler/StringSource.java

然后生成类的代码,并给编译器一个StringSource对象的列表:

1
2
List<StringSource> sources = List.of(new StringSource(className1, class1CodeString), ...);
task = compiler.getTask(null, fileManager, diagnostics, null, null, sources);

8.2.5 将字节码写出到内存

如果动态地编译类,就无需将类文件保存到磁盘。可以将其保存到内存中并立刻加载。

首先,要有一个类来持有这些字节:

compiler/ByteArrayClass.java

接下来,需要配置文件管理器使用该类作为输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
List<ByteArrayClass> classes = new ArrayList<>();
StandardJavaFileManager stdFileManager = compiler.getStandardFileManager(null, null, null);
JavaFileManager fileManager = new ForwardingJavaFileManager<JavaFileManager>(stdFileManager) {
    public JavaFileObject getJavaFileForOutput(
            Location location, String className, Kind kind, FileObject sibling) throws IOException {
        if (kind == Kind.CLASS) {
            ByteArrayClass outfile = new ByteArrayClass(className);
            classes.add(outfile);
            return outfile;
        }
        else
            return super.getJavaFileForOutput(location, className, kind, sibling);
    }
};

为了加载这个类,需要一个类加载器(参见第10章):

compiler/ByteArrayClassLoader.java

编译完成后,用上面的类加载器调用Class.forName()方法:

1
2
ByteArrayClassLoader loader = new ByteArrayClassLoader(classes);
Class<?> cl = Class.forName(className, true, loader);

(好麻烦……)

8.2.6 示例:动态Java代码生成

在实现动态网页的JSP技术中,可以将HTML与Java代码片段混合。例如:

1
<p>The current date and time is <b><%= new java.util.Date() %></b>.</p>

JSP引擎动态地将Java代码编译成Servlet。在示例应用中,我们使用了一个更简单的示例:动态生成Swing代码。其基本思想是使用GUI构建器在窗体中放置组件,并在一个外部文件中指定组件的行为。程序清单8-4展示了一个非常简单的窗体类示例,程序清单8-5展示了按钮动作的代码。注意,窗体类的构造器调用了抽象方法addEventHandlers()。代码生成器将产生一个实现了该方法的子类,对于action.properties文件中的每一行添加一个动作监听器。我们将这个子类放在名为x的包中,不希望在程序其他地方使用。生成的代码具有如下形式:

1
2
3
4
5
6
7
8
9
10
package x;

public class Frame extends SuperclassName {
    protected void addEventHandlers() {
        componentName1.addActionListener(event -> {
            // code for event handler1
        });
        // repeat for the other event handlers ...
    }
}

程序清单8-3中的buildSource()方法生成了这些代码,并将其放到StringSource对象中。该对象会被传递给Java编译器(getTask()方法的sources参数)。

我们使用了ForwardingJavaFileManager作为文件管理器(如前一节所述),它会为每个已编译的类构造一个ByteArrayClass对象,这些对象会捕获编译x.Frame类生成的类文件。

编译完成后,我们使用前一节中描述的ByteArrayClassLoader来加载窗体类,然后构造并显示应用程序的窗体。

1
2
3
var loader = new ByteArrayClassLoader(classFileObjects);
var frame = (JFrame) loader.loadClass("x.Frame").getConstructor().newInstance();
frame.setVisible(true);

程序清单8-3 compiler/CompilerTest.java

程序清单8-4 buttons2/ButtonFrame.java

程序清单8-5 buttons2/action.properties

点击按钮时,背景色会改变。为了验证这些动作是动态编译的,修改action.properties文件中的一行,例如:

1
yellowButton=panel.setBackground(java.awt.Color.YELLOW); yellowButton.setEnabled(false);

再次运行程序,现在Yellow按钮点击后会被禁用。查看代码目录,会发现没有任何x包中的资源或类文件。

8.3 使用注解

注解(annotation)是插入到源代码中、可以使用其他工具进行处理的标签。这些工具可以在源代码级别上操作,也可以处理包含注解的类文件。

注解不会改变程序的编译方式。Java编译器对于包含和不包含注解的代码会生成相同的虚拟机指令。

为了利用注解,需要选择一个处理工具。在代码中插入处理工具可以理解的注解,然后用处理工具处理代码。

注解的使用范围很广泛,例如:

  • 自动生成辅助文件,如部署描述符或bean信息类。
  • 自动生成测试、日志、事务语义等代码。

8.3.1 注解简介

下面是一个简单的注解示例:

1
2
3
4
5
public class MyClass {
    ...
    @Test
    public void checkRandomInsertions()
}

@Test用于注解checkRandomInsertions()方法。

在Java中,注解位于被注解项之前,名字以@开头。

@Test注解本身并不做任何事,需要工具支持才会有用。例如,JUnit测试工具(https://junit.org/)在测试一个类时会调用所有标注了@Test的方法。另一个工具可能会删除类文件中所有的测试方法,以免与程序一起发布。

注解可以包含元素,例如:

1
@Test(timeout = 10000)

元素可以被读取注解的工具处理。

除了方法,还可以注解类、字段和局部变量。注解可以放在任何可以放置修饰符(如publicstatic)的地方。另外,还可以注解包、参数、类型参数和类型用法。

注解必须通过注解接口定义。接口的方法对应注解的元素。例如,JUnit的Test注解由以下接口定义:

1
2
3
4
5
6
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
    long timeout() default 0L;
    ...
}

@interface声明创建了一个真实的Java接口。处理工具会接收到实现了该接口的对象,可以调用timeout()方法获取特定Test注解的timeout元素。

注解TargetRetention是元注解。它们注解了Test注解,将在8.5.2节详细讨论。

注释:对于有吸引力的注解用法,可以查看JCommander (https://jcommander.org/)和picocli (https://picocli.info/)。这些库将注解用于命令行参数的处理。

8.3.2 示例:注解事件处理器

在用户界面编程中,一件比较讨厌的事情是将监听器连接到事件源,例如:

1
myButton.addActionListener(event -> doSomething());

本节设计了一个注解来进行反向连接(将事件源标注到监听器方法)。该注解定义在程序清单8-8中,用法如下:

1
2
@ActionListenerFor(source = "myButton")
void doSomething() { ... }

程序清单8-7展示了卷I第10章的ButtonFrame类,但使用该注解重新实现了。

注解本身并不会做任何事情。现在需要一种机制来分析注解并安装动作监听器,这是ActionListenerInstaller类的职责。ButtonFrame构造器调用了这个类的静态方法processAnnotations()。该方法枚举给定对象(在示例程序中是ButtonFrame对象)的所有方法,对于每个方法,获得ActionListenerFor注解对象并处理它(如果有)。

1
2
3
4
5
Class<?> cl = obj.getClass();
for (Method m : cl.getDeclaredMethods()) {
    ActionListenerFor a = m.getAnnotation(ActionListenerFor.class);
    if (a != null) ...
}

这里使用了AnnotatedElement接口的getAnnotation()方法。MethodConstructorFieldClassPackage类都实现了这个接口。

可以通过调用注解对象的source()方法获取源字段的名字,然后查找匹配的字段(如yellowButton)。

1
2
String fieldName = a.source();
Field f = cl.getDeclaredField(fieldName);

对于每个被注解的方法,构造了一个实现ActionListener接口的代理对象,其actionPerformed()方法调用被注解的方法(监听器)(关于代理详见卷I第6章 6.5节)。

程序清单8-6 runtimeAnnotations/ActionListenerInstaller.java

程序清单8-7 buttons3/ButtonFrame.java

程序清单8-8 runtimeAnnotations/ActionListenerFor.java

在这个示例中,注解是在运行时处理的。也可以在源码级别处理:源代码生成器产生用于添加监听器的代码。或者,也可以在字节码级别处理:字节码编辑器将addActionListener()调用注入窗体构造器。

8.4 注解语法

8.4.1 注解接口

注解是由注解接口(annotation interface)定义的:

1
2
3
4
5
modifiers @interface AnnotationName {
    elementDeclaration1
    elementDeclaration2
    ...
}

每个元素声明形如

1
type elementName();

或者

1
type elementName() default value;

例如,下面的注解有两个元素,assignedToseverity

1
2
3
4
public @interface BugReport {
    String assignedTo() default "[none]";
    int severity();
}

所有注解接口都隐式地扩展了java.lang.annotation.Annotation接口。这个接口是常规接口,不是注解接口。不能扩展注解接口——换句话说,所有注解接口都直接扩展自Annotation。也从不会为注解接口提供实现类(虽然可以但无意义)。

注解接口的方法没有参数和throws子句,不能是defaultstatic方法,也不能有类型参数。

注解元素的类型为下列之一:

  • 基本类型
  • String
  • Class(具有可选的类型参数,例如Class<? extends MyClass>
  • 枚举类型
  • 注解类型
  • 上述类型的数组(多维数组是非法的)

例如:

1
2
3
4
5
6
7
8
9
public @interface BugReport {
    enum Status { UNCONFIRMED, CONFIRMED, FIXED, NOTABUG };
    boolean showStopper() default false;
    String assignedTo() default "[none]";
    Class<?> testCase() default Void.class;
    Status status() default Status.UNCONFIRMED;
    Reference ref() default @Reference(); // an annotation type
    String[] reportedBy();
}

8.4.2 注解

注解具有以下格式:

1
@AnnotationName(elementName1 = value1, elementName2 = value2, ...)

例如

1
@BugReport(assignedTo = "Harry", severity = 10)

元素的顺序无关紧要。

如果未指定某个元素的值,则使用声明的默认值。例如:

1
@BugReport(severity = 10)  // assignedTo is "[none]"

警告:默认值不是和注解一起存储的,而是动态计算的。例如,如果将assignedTo元素的默认值改为"[]"并重新编译BugReport接口,注解@BugReport(severity = 10)将使用新的默认值(即使它所在的类文件没有重新编译)。

有两个特殊的捷径可以简化注解。如果没有指定元素(因为注解没有任何元素,或者所有元素都使用默认值),就不需要使用圆括号。例如,@BugReport等价于@BugReport(assignedTo = "[none]", severity = 0)。这种注解称为标记注解(marker annotation)。

另一种捷径是单值注解(single-value annotation)。如果仅指定一个具有特殊名字value的元素,就可以省略元素名和等号。例如,假设将前一节的注解接口ActionListenerFor定义为

1
2
3
public @interface ActionListenerFor {
    String value();
}

就可以将注解写成@ActionListenerFor("yellowButton")

一个项可以有多个注解,例如:

1
2
3
@Test
@BugReport(showStopper = true, reportedBy = "Joe")
public void checkRandomInsertions()

如果注解声明为可重复的(使用@Repeatable),就可以多次重复使用同一个注解:

1
2
3
@BugReport(showStopper = true, reportedBy = "Joe")
@BugReport(reportedBy = {"Harry", "Carl"})
public void checkRandomInsertions()

注释:注解的所有元素值必须是编译时常量。

警告:注解元素永远不能设置为null,甚至不允许默认值为null。必须使用其他的默认值,例如""或者Void.class

如果元素值是数组,需要将它的值用花括号括起来:

1
@BugReport(reportedBy = {"Harry", "Carl"})

如果数组只有单个值,就可以省略花括号:

1
@BugReport(reportedBy = "Joe") // OK, same as {"Joe"}

由于注解元素可以是另一个注解,你可以构建出任意复杂的注解。例如,

1
@BugReport(ref = @Reference(id = "3352627"), ...)

注释:在注解中引入循环依赖是错误的。例如,BugReport具有注解类型Reference的元素,因此Reference不能有BugReport类型的元素。

8.4.3 注解声明

注解可以出现在许多地方,这些地方可分为两类:声明和类型用法。声明注解可以出现在下列声明处:

  • 类(包括enum
  • 接口(包括注解接口)
  • 方法
  • 构造器
  • 实例字段(包括enum常量)
  • 局部变量
  • 参数
  • 类型参数

对于类和接口,将注解放在classinterface关键字前:

1
@Entity public class User { ... }

对于变量和参数,将其放在类型前:

1
2
@SuppressWarnings("unchecked") List<User> users = ...;
public User getUser(@Param("id") String userId)

泛型类或方法中的类型参数可以像这样注解:

1
public class Cache<@Immutable V> { ... }

包在文件package-info.java中注解,该文件只包含包语句:

1
2
3
4
5
6
/**
 * Package-level Javadoc
 */
@GPL(version = "3")
package com.horstmann.corejava;
import org.gnu.GPL;

注释:局部变量的注解只能在源码级别处理(因为在编译完后就会被丢弃)。类似地,包注解也不会在源码级别之外保留。

8.4.4 注解类型用法

类型用法(type use)注解可以出现在下列位置:

  • 类型参数:List<@NonNull String>, Comparator.<@NonNull String>reverseOrder()
  • 数组的任何位置:
    • @NonNull String[][] wordswords[i][j]不为null
    • String @NonNull [][] wordswords不为null
    • String[] @NonNull [] wordswords[i]不为null
  • 超类和实现接口:class Warning extends @Localized Message
  • 构造器调用:new @Localized String(...)
  • 强制类型转换和instanceof检查:(@Localized String) text, if (text instanceof @Localized String)(这些注解仅供外部工具使用,对类型转换和instanceof检查的行为没有影响)
  • 异常声明:public String read() throws @Localized IOException
  • 通配符和类型边界:List<@Localized ? extends Message>, List<? extends @Localized Message>
  • 方法和构造器引用:@Localized Message::getText

有些位置不能被注解:

1
2
@NonNull String.class // ERROR: Cannot annotate class literal
import java.lang.@NonNull String; // ERROR: Cannot annotate import

可以将注解放在修饰符的前面或后面。习惯(但不是必需)的做法是:将类型用法注解放在修饰符之后,将声明注解放在修饰符之前。例如,

1
2
private @NonNull String text; // Annotates the type use
@Id private String userId; // Annotates the variable

在注解记录组件(见卷I第4章 4.7节)时,注解也应用于生成的字段、getter方法和构造器参数。

注释:注解的作者需要指定注解可以出现在哪里。如果一个注解既可用于声明也可用于类型用法,则当它被用于变量声明时,该变量和类型用法都会被注解。例如:

1
public User getUser(@NonNull String userId)

如果@NonNull可同时用于声明和类型用法,那么userId参数被注解了,并且参数类型是@NonNull String

8.4.5 注解this

假设想要注解不会被方法修改的参数:

1
2
3
public class Point {
    public boolean equals(@ReadOnly Object other) { ... }
}

那么处理这个注解的工具在看到调用p.equals(q)时就会推理出q没有被修改过。当该方法被调用时,this变量绑定到p。但是this从来没有声明过,因此无法注解它。

实际上,可以用一种很少使用的语法变体来声明它,这样就可以添加注解了:

1
2
3
public class Point {
    public boolean equals(@ReadOnly Point this, @ReadOnly Object other) { ... }
}

第一个参数称为接收器参数(receiver parameter),它必须命名为this,其类型是方法所属的类。

注释:不能为构造器提供接收器参数(内部类除外)。从概念上讲,构造器中的this引用在构造器没有执行完之前不是给定类型的对象。放在构造器上的注解描述了被构造对象的属性。

内部类构造器的接收器参数是外部类对象的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Sequence {
    private int from;
    private int to;

    class Iterator implements java.util.Iterator<Integer> {
        private int current;
        public Iterator(@ReadOnly Sequence Sequence.this) {
            this.current = Sequence.this.from;
        }
        ...
    }
    ...
}

8.5 标准注解

java.langjava.lang.annotationjavax.annotation包中定义了许多注解接口。下表展示了这些标准注解。

注解接口应用场合目的
Deprecated所有标记为已弃用
SuppressWarnings除了包和注解抑制给定类型的警告
SafeVarargs方法和构造器断言可变参数可以安全使用
Override方法检查该方法覆盖了超类方法
Serial方法检查该方法是正确的序列化方法
FunctionalInterface方法标记为函数式接口(具有单个抽象方法)
Generated所有标记为由工具生成的源代码
Target注解指定该注解可应用的项
Retention注解指定该注解保留多久
Documented注解指定该注解应该包含在被注解项的文档中
Inherited注解指定当该注解应用于类时自动被子类继承
Repeatable注解指定该注解可以多次应用于同一个项

8.5.1 用于编译的注解

@Deprecated注解可以添加到任何不再鼓励使用的项。当使用已弃用的项时,编译器会发出警告。该注解与Javadoc标签@deprecated具有相同的作用,但注解会保留到运行时。

注释:JDK自带的jdeprscan工具可以扫描JAR文件中已弃用的元素。

@SuppressWarnings注解告诉编译器抑制特定类型的警告。例如,@SuppressWarnings("unchecked")

@Override注解只能应用于方法。编译器会检查具有该注解的方法真正覆盖了一个超类方法。例如,如果声明

1
2
3
4
public MyClass {
    @Override public boolean equals(MyClass other);
    ...
}

那么编译器会报错——该方法没有覆盖Object类的equals()方法,因为其参数类型是Object而不是MyClass

@Generated注解旨在供代码生成工具使用,用于将生成的代码与程序员编写的代码区分开。每个注解必须包含表示代码生成器的唯一标识符,日期字符串(ISO 8601格式)和注释字符串是可选的。例如,

1
@Generated("com.horstmann.beanproperty", "2008-01-04T12:08:56.235-0700");

8.5.2 元注解

元注解(meta-annotation)即“注解的注解”。

元注解@Target用于限制注解可应用的项。例如,

1
2
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface BugReport

下表列出了所有可能的取值。它们属于枚举类型ElementType。可以指定任意数量的值,用花括号括起来。

元素类型应用场合
ANNOTATION_TYPE注解类型声明
PACKAGE
TYPE类(包括枚举)和接口(包括注解接口)
METHOD方法
CONSTRUCTOR构造器
FIELD字段(包括枚举常量)
PARAMETER方法或构造器参数
LOCAL_VARIABLE局部变量
TYPE_PARAMETER类型参数
TYPE_USE类型用法

没有@Target限制的注解可应用于任何项。如果将注解应用于不允许的项会导致编译错误。

元注解@Retention指定注解保留多久,可以指定下表中的值之一(属于枚举类型RetentionPolicy),默认值是CLASS

保留策略描述
SOURCE注解不包括在类文件中
CLASS注解包括在类文件中,但虚拟机不加载
RUNTIME注解包括在类文件中,并由虚拟机加载,可通过反射获得

在程序清单8-8中,ActionListenerFor注解声明为RUNTIME,因为使用了反射来处理注解。在下面两节中将会看到在源码和类文件级别处理注解的示例。

元注解@Documented为诸如Javadoc这样的文档工具提供了一些提示。Javadoc会在输出的文档中显示带有@Documented的注解,而其他注解不会包含在文档中。例如,假设将ActionListenerFor声明为

1
2
3
4
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ActionListenerFor

现在每个被该注解标注的方法的文档就会显示该注解,如下图所示。

文档化注解

如果一个注解是暂时性的(例如@BugReport),就不应该将其包含在文档中。

注释:将注解应用于自身是合法的。例如,@Documented本身被注解为@Documented

元注解@Inherited只能应用于类的注解。如果一个类具有继承注解,那么它的所有子类都自动具有相同的注解。这使得创建与标记接口(如Serializable)具有相同作用的注解变得很容易。

例如,假设定义了一个继承注解@Persistent来指示一个类的对象可以存储到数据库中,那么该类的子类会自动被注解为持久性的。

1
2
3
@Inherited @interface Persistent {}
@Persistent class Employee { ... }
class Manager extends Employee { ... } // also @Persistent

从Java 8起,将同一注解类型多次应用于一个项是合法的。为了向后兼容,可重复注解的实现者需要提供一个容器注解(container annotation),将重复的注解包装到一个数组中。

下面定义了可重复注解@TestCase及其容器注解@TestCases

1
2
3
4
5
6
7
8
9
@Repeatable(TestCases.class)
@interface TestCase {
    String params();
    String expected();
}

@interface TestCases {
    TestCase[] value();
}

警告:处理可重复注解时必须小心。如果调用getAnnotation()来查找可重复注解,而该注解确实重复了多次,就会得到null(如果只出现一次则不为null)。这是因为重复的注解被包装到了容器注解中。在这种情况下,应该调用getAnnotationsByType(),该方法返回重复注解的数组。使用这个方法就不用操心容器注解了。

1
2
3
4
5
6
@TestCase(params = "Hello", expected = "hello")
public String toLower(String s) { ... }

@TestCase(params = "Hello", expected = "HELLO")
@TestCase(params = "world", expected = "WORLD")
public String toUpper(String s) { ... }
1
2
3
4
5
6
7
Method lower = cl.getMethod("toLower", String.class);
TestCase lowerTestCase = lower.getAnnotation(TestCase.class); // not null
TestCase[] lowerTestCases = lower.getAnnotationsByType(TestCase.class); // length = 1

Method upper = cl.getMethod("toUpper", String.class);
TestCase upperTestCase = upper.getAnnotation(TestCase.class); // null
TestCase[] upperTestCases = upper.getAnnotationsByType(TestCase.class); // length = 2

8.6 源码级注解处理

注解的另一种用法是自动处理源文件以产生源代码、配置文件、脚本或任何其他你想生成的东西。

8.6.1 注解处理器

注解处理已经集成到了Java编译器中。在编译时,可以通过运行以下命令来调用注解处理器:

1
javac -processor ProcessorClassName1,ProcessorClassName2,... sourceFiles

编译器会定位源文件中的注解。每个注解处理器依次执行,并得到它感兴趣的注解。如果某个注解处理器生成了新的源文件,则重复上述过程。一旦一轮处理没有产生更多源文件,就会编译所有源文件。

注释:注解处理器只能生成新的源文件,不能修改已有的源文件。

注解处理器需要实现javax.annotation.processing.Processor接口,一般是通过扩展AbstractProcessor类。需要指定处理器支持的注解,例如:

1
2
3
4
5
6
7
@SupportedAnnotationTypes("com.horstmann.annotations.ToString")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class ToStringAnnotationProcessor extends AbstractProcessor {
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment currentRound) {
        ...
    }
}

处理器可以声明具体注解类型、通配符(例如"com.horstmann.*"表示com.horstmann包及其子包中的所有注解),或者"*"(所有注解)。

process()方法每轮被调用一次,参数为这一轮在所有文件中发现的所有注解构成的集,以及包含有关当前处理轮次信息的对象。

8.6.2 语言模型API

使用语言模型(language model) API来分析源码级注解。与呈现类和方法的虚拟机表示的反射API不同,语言API可以根据Java语法规则分析Java程序(注:类似于编译器的语法分析)。

编译器会产生一颗树(语法分析树),其节点是实现了javax.lang.model.element.Element接口及其TypeElementVariableElementExecutableElement等子接口的类的实例。这些节点类比于ClassField/ParameterMethod/Constructor反射类。

本节不会详细介绍该API,以下是处理注解需要了解的要点:

  • RoundEnvironment接口的getElementsAnnotatedWith()方法返回具有特定注解的所有元素构成的集。
  • 源码级别上等价于AnnotatedElement接口的是AnnotatedConstruct。同样可以使用getAnnotation()getAnnotationsByType()方法获得属于给定类型的注解或重复注解。
  • TypeElement表示类或接口,getEnclosedElements()方法产生其字段和方法构成的列表。
  • 调用Element.getSimpleName()TypeElement.getQualifiedName()会产生一个Name对象,可以用toString()转换为字符串。

8.6.3 使用注解生成源码

作为示例,本节将使用注解来减少实现toString()方法时枯燥的工作量。

不能将这些方法放到原来的类中,因为注解处理器不能修改已有的类。因此,将所有自动生成的方法添加到工具类ToStrings中(这个类也是生成的):

1
2
3
4
5
6
7
8
9
10
11
12
public class ToStrings {
    public static String toString(Point obj) {
        // Generated code
    }
    public static String toString(Rectangle obj) {
        // Generated code
    }
    ...
    public static String toString(Object obj) {
        return Objects.toString(obj);
    }
}

我们不想使用反射,因此注解访问器方法而不是字段:

1
2
3
4
5
6
7
@ToString
public class Rectangle {
    ...
    @ToString(includeName = false) public Point getTopLeft() { return topLeft; }
    @ToString public int getWidth() { return width; }
    @ToString public int getHeight() { return height; }
}

然后注解处理器应该生成下面的源代码(除了类名和字段名外都是“样板”代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static String toString(Rectangle obj) {
    var result = new StringBuilder();
    result.append("Rectangle");
    result.append("[");
    result.append(toString(obj.getTopLeft()));
    result.append(",");
    result.append("width=");
    result.append(toString(obj.getWidth()));
    result.append(",");
    result.append("height=");
    result.append(toString(obj.getHeight()));
    result.append("]");
    return result.toString();
}

下面是为给定的类生成toString()方法的框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void writeToStringMethod(PrintWriter out, TypeElement te) {
    String className = te.getQualifiedName().toString();
    // Print method header and declaration of string builder
    ToString ann = te.getAnnotation(ToString.class);
    if (ann.includeName())
        // Print code to add class name
    for (Element c : te.getEnclosedElements()) {
        ann = c.getAnnotation(ToString.class);
        if (ann != null) {
            if (ann.includeName()) // Print code to add field name
            // Print code to append toString(obj.methodName())
        }
    }
    // Print code to return string
}

下面是注解处理器的process()方法的框架。它会创建工具类ToStrings的源文件,并为每个被注解的类创建一个静态toString()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment currentRound) {
    if (annotations.size() == 0) return true;
    try {
        JavaFileObject sourceFile = processingEnv.getFiler().createSourceFile(
            "com.horstmann.annotations.ToStrings");
        try (var out = new PrintWriter(sourceFile.openWriter())) {
            // Print code for package and class
            for (Element e : currentRound.getElementsAnnotatedWith(ToString.class))
                if (e instanceof TypeElement te)
                    writeToStringMethod(out, te);
            // Print code for toString(Object)
        }
    }
    catch (IOException e) {
        processingEnv.getMessager().printMessage(Kind.ERROR, ex.getMessage());
    }
    return true;
}

注意,process()方法在后续轮次中是用空列表调用的,然后它会立即返回,不会再次创建源文件。

sourceAnnotations/ToStringAnnotationProcessor.java

rect/SourceLevelAnnotationDemo.java

首先编译注解处理器,然后编译并运行测试程序:

1
2
3
javac sourceAnnotations/ToStringAnnotationProcessor.java
javac -processor sourceAnnotations.ToStringAnnotationProcessor rect/*.java
java rect.SourceLevelAnnotationDemo

提示:要查看轮次,可以用-XprintRounds选项运行javac命令。

除了源文件,注解处理器还可以生成XML描述符、属性文件、shell脚本、HTML文档等。

注释:有人建议使用注解来自动生成getter和setter。例如:

1
@Property private String title;

可以产生方法

1
2
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }

但是,这需要编辑源文件而不只是生成另一个文件,这超出了注解处理器的能力范围。注解旨在作为对代码项的描述,而不是添加或修改代码的指令。

8.7 字节码工程

除了运行时和源码级别,还可以在字节码级别处理注解。除非注解在源码级别被删除,否则就会出现在类文件中。类文件的格式相当复杂(参见Java虚拟机规范第4章),在没有特殊库的情况下处理类文件具有很大的挑战性。ASM库就是这种库之一,可以从 https://asm.ow2.io/ 获得。下载asm-9.2.jar和asm-commons-9.2.jar并将其放到某个目录中(下面将其称为$asm)。

8.7.1 修改类文件

在本节中,使用ASM为注解的方法添加日志消息。如果一个方法具有注解

1
@LogEntry(logger = loggerName)

则在方法开头添加以下语句的字节码:

1
Logger.getLogger(loggerName).entering(className, methodName);

例如,如果对Item类的hashCode()方法添加了注解@LogEntry(logger = "global"),那么每当调用该方法时就会打印类似于下面的消息:

1
2
May 17, 2016 10:57:59 AM Item hashCode
FINER: ENTRY

为此,需要执行以下步骤:

  1. 加载类文件中的字节码。
  2. 定位所有方法。
  3. 对于每个方法,检查它是否具有LogEntry注解。
  4. 如果有,在方法开头添加下列指令的字节码:
1
2
3
4
5
ldc loggerName
invokestatic java/util/logging/Logger.getLogger:(Ljava/lang/String;)Ljava/util/logging/Logger;
ldc className
ldc methodName
invokevirtual java/util/logging/Logger.entering:(Ljava/lang/String;Ljava/lang/String;)V

这看起来很棘手,不过ASM使它变得相当简单。程序清单8-9中的程序可以编辑一个类文件,并在具有LogEntry注解的方法开头插入日志调用。例如,可以像这样向程序清单8-10中的Item.java添加日志指令(其中$asm是ASM库的安装目录):

1
2
3
javac set/Item.java
javac -classpath .:$asm/\* bytecodeAnnotations/EntryLogger.java
java -classpath .:$asm/\* bytecodeAnnotations.EntryLogger set/Item.class

在修改Item类文件前后分别运行一下javap -c set.Item,可以看到在hashCode()equals()compareTo()方法开头插入的指令。

1
2
3
4
5
6
7
8
9
10
public int hashCode();
  Code:
     0: ldc           #42    // String com.horstmann
     2: invokestatic  #48    // Method java/util/logging/Logger.getLogger:(Ljava/lang/String;)Ljava/util/logging/Logger;
     5: ldc           #50    // String set.Item
     7: ldc           #67    // String hashCode
     9: invokevirtual #55    // Method java/util/logging/Logger.entering:(Ljava/lang/String;Ljava/lang/String;)V
    12: iconst_2
    13: anewarray     #4     // class java/lang/Object
    ...

程序清单8-11中的SetTest程序将Item对象插入到散列集中。当使用修改过的类文件来运行该程序时,将会看到日志消息:

1
2
3
4
5
6
7
8
9
May 17, 2016 10:57:59 AM Item hashCode
FINER: ENTRY
May 17, 2016 10:57:59 AM Item hashCode
FINER: ENTRY
May 17, 2016 10:57:59 AM Item hashCode
FINER: ENTRY
May 17, 2016 10:57:59 AM Item equals
FINER: ENTRY
[[description=Toaster, partNumber=1729], [description=Microwave, partNumber=4104]]

程序清单8-9 bytecodeAnnotations/EntryLogger.java

程序清单8-10 set/Item.java

程序清单8-11 set/SetTest.java

注意:必须按以下顺序编译和运行:

  1. 编译Item
  2. 编译EntryLogger
  3. 运行EntryLogger类,修改Item.class
  4. 编译SetTest
  5. 运行SetTest程序

8.7.2 在加载时修改字节码

上一节介绍了编辑类文件的工具。一种有吸引力的替代方案是将字节码工程推迟到加载时,即类加载器加载类的时候。

Java instrumentation API 提供了一个安装字节码转换器的挂钩(hook)。转换器必须在程序的main()方法被调用前安装,可以通过定义一个代理(agent)(以某种方式监视程序的库)来满足这一要求。代理代码可以在premain()方法中执行初始化。

下面是构建代理所需的步骤:

1.实现一个具有以下方法的类。当代理加载时该方法会被调用。代理可以获取单个命令行参数,通过arg参数传递。instr参数可以用来安装各种挂钩。

1
public static void premain(String arg, Instrumentation instr)

2.制作一个清单文件(例如EntryLoggingAgent.mf),设置Premain-Class。例如:

1
Premain-Class: bytecodeAnnotations.EntryLoggingAgent

3.将代理代码和清单打包成一个JAR文件:

1
2
javac -classpath .:$asm/\* bytecodeAnnotations/EntryLoggingAgent.java
jar cvfm EntryLoggingAgent.jar bytecodeAnnotations/EntryLoggingAgent.mf bytecodeAnnotations/Entry*.class

为了与代理一起启动Java程序,使用以下命令:

1
java -javaagent:AgentJARFile=agentArgument ...

例如,运行具有入口日志代理的SetTest程序:

1
2
javac set/SetTest.java
java -javaagent:EntryLoggingAgent.jar=set.Item -classpath .:$asm/\* set.SetTest

其中set.Item参数是代理应该修改的类名。

程序清单8-12展示了这个代理的代码。代理安装了一个类文件转换器,这个转换器首先检查类名是否与代理参数匹配。如果匹配,则使用上一节的EntryLogger类修改字节码。不过,修改后的字节码没有保存到文件,而是转换器将其返回以加载到虚拟机中(见下图)。换句话说,这项技术对字节码进行“即时”(just in time)修改。

在加载时修改类

程序清单8-12 bytecodeAnnotations/EntryLoggingAgent.java

This post is licensed under CC BY 4.0 by the author.