1. 概述
Java 8提供了一个用于创建Javac插件的API,不幸的是,很难找到关于它的良好文档。
在本文中,我们将展示创建编译器扩展的整个过程,该扩展将自定义代码添加到*.class文件。
2. 设置
首先,我们需要添加JDK的tools.jar作为我们项目的依赖:
<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8.0</version>
<scope>system</scope>
<systemPath>${java.home}/../lib/tools.jar</systemPath>
</dependency>
每个编译器扩展都是一个实现com.sun.source.util.Plugin接口的类,让我们在示例中创建它:
public class SampleJavacPlugin implements Plugin {
@Override
public String getName() {
return "MyPlugin";
}
@Override
public void init(JavacTask task, String... args) {
Context context = ((BasicJavacTask) task).getContext();
Log.instance(context).printRawLines(Log.WriterKind.NOTICE, "Hello from " + getName());
}
}
现在,我们只是打印“Hello”以确保我们的代码被成功提取并包含在编译中。
我们的最终目标是创建一个插件,为每个用给定注解标记的数字参数添加运行时检查,并在参数不匹配条件时抛出异常。
要使扩展可被Javac发现,还有一个必要的步骤:它应该通过ServiceLoader框架公开。
为此,我们需要创建一个名为com.sun.source.util.Plugin的文件,其内容是我们插件的完全限定类名(cn.tuyucheng.taketoday.javac.SampleJavacPlugin),并将其放在META-INF/services目录中。
之后,我们可以使用-Xplugin:MyPlugin开关调用Javac:
tuyucheng/tutorials$ javac -cp ./core-java/target/classes -Xplugin:MyPlugin ./core-java/src/main/java/cn/tuyucheng/taketoday/javac/TestClass.java
Hello from MyPlugin
请注意,我们必须始终使用从插件的getName()方法返回的字符串作为-Xplugin选项值。
3. 插件生命周期
编译器仅通过init()方法调用一次插件。
要收到后续事件的通知,我们必须注册一个回调,这些回调在每个源文件的每个处理阶段之前和之后到达:
- PARSE:构建抽象语法树(AST)
- ENTER:源代码导入已解决
- ANALYZE:分析解析器输出(AST)是否存在错误
- GENERATE:为目标源文件生成二进制文件
还有两种事件类型-ANNOTATION_PROCESSING和ANNOTATION_PROCESSING_ROUND,但我们在这里对它们不感兴趣。
例如,当我们想通过添加一些基于源代码信息的检查来增强编译时,在PARSE finished事件处理程序中这样做是合理的:
public void init(JavacTask task, String... args){
task.addTaskListener(new TaskListener(){
public void started(TaskEvent e){
}
public void finished(TaskEvent e){
if(e.getKind()!= TaskEvent.Kind.PARSE){
return;
}
// Perform instrumentation
}
});
}
4. 提取AST数据
我们可以通过TaskEvent.getCompilationUnit()获取Java编译器生成的AST,可以通过TreeVisitor接口检查其详细信息。
请注意,只有调用了accept()方法的Tree元素才会将事件分派给给定的访问者。
例如,当我们执行ClassTree.accept(visitor)时,只会触发visitClass();例如,我们不能期望给定类中的每个方法都激活visitMethod()。
我们可以使用TreeScanner来克服这个问题:
public void finished(TaskEvent e){
if(e.getKind()!= TaskEvent.Kind.PARSE){
return;
}
e.getCompilationUnit().accept(new TreeScanner<Void, Void>(){
@Override
public Void visitClass(ClassTree node, Void aVoid){
return super.visitClass(node, aVoid);
}
@Override
public Void visitMethod(MethodTree node, Void aVoid){
return super.visitMethod(node, aVoid);
}
}, null);
}
在这个例子中,需要调用super.visitXxx(node, value)来递归处理当前节点的子节点。
5. 修改AST
为了展示我们如何修改AST,我们将为所有标有@Positive注解的数字参数插入运行时检查。
这是一个可以应用于方法参数的简单注解:
@Documented
@Retention(RetentionPolicy.CLASS)
@Target({ElementType.PARAMETER})
public @interface Positive { }
这是使用注解的示例:
public void service(@Positive int i){ }
最后,我们希望字节码看起来像是从这样的源代码编译而来的:
public void service(@Positive int i){
if(i <= 0){
throw new IllegalArgumentException("A non-positive argument(" + i + ")is given as a @Positive parameter 'i'");
}
}
这意味着我们希望为每个标有@Positive且等于或小于0的参数抛出IllegalArgumentException。
5.1 Instrument
让我们来看看如何找到应该应用检测的目标位置:
private static Set<String> TARGET_TYPES = Stream.of(
byte.class, short.class, char.class,
int.class, long.class, float.class, double.class)
.map(Class::getName)
.collect(Collectors.toSet());
为简单起见,我们在这里只添加了原始数字类型。
接下来,让我们定义一个shouldInstrument()方法来检查参数是否具有TARGET_TYPES集中的类型以及@Positive注解:
private boolean shouldInstrument(VariableTree parameter){
return TARGET_TYPES.contains(parameter.getType().toString())
&& parameter.getModifiers().getAnnotations().stream()
.anyMatch(a -> Positive.class.getSimpleName()
.equals(a.getAnnotationType().toString()));
}
然后我们将继续我们的SampleJavacPlugin类中的finished()方法,对满足我们条件的所有参数应用检查:
public void finished(TaskEvent e) {
if (e.getKind() != TaskEvent.Kind.PARSE) {
return;
}
e.getCompilationUnit().accept(new TreeScanner<Void, Void>() {
@Override
public Void visitMethod(MethodTree method, Void v) {
List<VariableTree> parametersToInstrument
= method.getParameters().stream()
.filter(SampleJavacPlugin.this::shouldInstrument)
.collect(Collectors.toList());
if (!parametersToInstrument.isEmpty()) {
Collections.reverse(parametersToInstrument);
parametersToInstrument.forEach(p -> addCheck(method, p, context));
}
return super.visitMethod(method, v);
}
}, null);
在此示例中,我们反转了参数列表,因为可能存在不止一个参数被@Positive标记的情况。由于每个检查都被添加为第一个方法指令,我们对其进行RTL处理以确保正确的顺序。
5.2 如何检测
问题在于“读取AST”位于public API区域,而“修改AST”操作(如“添加空检查”)是private API。
为了解决这个问题,我们将通过TreeMaker实例创建新的AST元素。
首先,我们需要获取一个Context实例:
@Override
public void init(JavacTask task, String... args){
Context context =((BasicJavacTask)task).getContext();
// ...
}
然后,我们可以通过TreeMarker.instance(Context)方法获取TreeMarker对象。
现在我们可以构建新的AST元素,例如,可以通过调用TreeMaker.If()来构建if表达式:
private static JCTree.JCIf createCheck(VariableTree parameter, Context context){
TreeMaker factory = TreeMaker.instance(context);
Names symbolsTable = Names.instance(context);
return factory.at(((JCTree)parameter).pos)
.If(factory.Parens(createIfCondition(factory, symbolsTable, parameter)),
createIfBlock(factory, symbolsTable, parameter),
null);
}
请注意,当我们的检查抛出异常时,我们希望显示正确的堆栈跟踪行。这就是为什么我们在使用factory.at(((JCTree)parameter).pos)通过它创建新元素之前调整AST工厂位置。
createIfCondition()方法构建“parameterId < 0” if条件:
private static JCTree.JCBinary createIfCondition(TreeMaker factory,
Names symbolsTable, VariableTree parameter){
Name parameterId = symbolsTable.fromString(parameter.getName().toString());
return factory.Binary(JCTree.Tag.LE,
factory.Ident(parameterId),
factory.Literal(TypeTag.INT, 0));
}
接下来,createIfBlock()方法构建一个返回IllegalArgumentException的块:
private static JCTree.JCBlock createIfBlock(TreeMaker factory, Names symbolsTable, VariableTree parameter){
String parameterName = parameter.getName().toString();
Name parameterId = symbolsTable.fromString(parameterName);
String errorMessagePrefix = String.format("Argument '%s' of type %s is marked by @%s but got '", parameterName, parameter.getType(), Positive.class.getSimpleName());
String errorMessageSuffix = "' for it";
return factory.Block(0, com.sun.tools.javac.util.List.of(factory.Throw(
factory.NewClass(null, nil(),
factory.Ident(symbolsTable.fromString(
IllegalArgumentException.class.getSimpleName())),
com.sun.tools.javac.util.List.of(factory.Binary(JCTree.Tag.PLUS,
factory.Binary(JCTree.Tag.PLUS,
factory.Literal(TypeTag.CLASS, errorMessagePrefix),
factory.Ident(parameterId)),
factory.Literal(TypeTag.CLASS, errorMessageSuffix))), null))));
}
现在我们能够构建新的AST元素,我们需要将它们插入到解析器准备的AST中。我们可以通过将公共API元素转换为私有API类型来实现此目的:
private void addCheck(MethodTree method, VariableTree parameter, Context context){
JCTree.JCIf check = createCheck(parameter, context);
JCTree.JCBlock body =(JCTree.JCBlock)method.getBody();
body.stats = body.stats.prepend(check);
}
6. 测试插件
我们需要能够测试我们的插件,它涉及以下内容:
- 编译测试源
- 运行已编译的二进制文件并确保它们的行为符合预期
为此,我们需要引入一些工具类。
SimpleSourceFile将给定源文件的文本公开给Javac:
public class SimpleSourceFile extends SimpleJavaFileObject {
private String content;
public SimpleSourceFile(String qualifiedClassName, String testSource) {
super(URI.create(String.format(
"file://%s%s", qualifiedClassName.replaceAll("\\.", "/"),
Kind.SOURCE.extension)), Kind.SOURCE);
content = testSource;
}
@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors) {
return content;
}
}
SimpleClassFile将编译结果保存为字节数组:
public class SimpleClassFile extends SimpleJavaFileObject {
private ByteArrayOutputStream out;
public SimpleClassFile(URI uri){
super(uri, Kind.CLASS);
}
@Override
public OutputStream openOutputStream()throws IOException {
return out = new ByteArrayOutputStream();
}
public byte[] getCompiledBinaries(){
return out.toByteArray();
}
// getters
}
SimpleFileManager确保编译器使用我们的字节码持有者:
public class SimpleFileManager extends ForwardingJavaFileManager<StandardJavaFileManager> {
private List<SimpleClassFile> compiled = new ArrayList<>();
// standard constructors/getters
@Override
public JavaFileObject getJavaFileForOutput(Location location,
String className, JavaFileObject.Kind kind, FileObject sibling){
SimpleClassFile result = new SimpleClassFile(
URI.create("string://" + className));
compiled.add(result);
return result;
}
public List<SimpleClassFile> getCompiled(){
return compiled;
}
}
最后,所有这些都绑定到内存中的编译:
public class TestCompiler {
public byte[] compile(String qualifiedClassName, String testSource){
StringWriter output = new StringWriter();
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
SimpleFileManager fileManager = new SimpleFileManager(
compiler.getStandardFileManager(null, null, null));
List<SimpleSourceFile> compilationUnits
= singletonList(new SimpleSourceFile(qualifiedClassName, testSource));
List<String> arguments = new ArrayList<>();
arguments.addAll(asList("-classpath", System.getProperty("java.class.path"),
"-Xplugin:" + SampleJavacPlugin.NAME));
JavaCompiler.CompilationTask task
= compiler.getTask(output, fileManager, null, arguments, null,
compilationUnits);
task.call();
return fileManager.getCompiled().iterator().next().getCompiledBinaries();
}
}
之后,我们只需要运行二进制文件:
public class TestRunner {
public Object run(byte[] byteCode, String qualifiedClassName, String methodName,
Class<?>[] argumentTypes, Object... args) throws Throwable {
ClassLoader classLoader = new ClassLoader() {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
return defineClass(name, byteCode, 0, byteCode.length);
}
};
Class<?> clazz;
try {
clazz = classLoader.loadClass(qualifiedClassName);
} catch (ClassNotFoundException e) {
throw new RuntimeException("Can't load compiled test class", e);
}
Method method;
try {
method = clazz.getMethod(methodName, argumentTypes);
} catch (NoSuchMethodException e) {
throw new RuntimeException(
"Can't find the 'main()' method in the compiled test class", e);
}
try {
return method.invoke(null, args);
} catch (InvocationTargetException e) {
throw e.getCause();
}
}
}
测试可能如下所示:
public class SampleJavacPluginTest {
private static final String CLASS_TEMPLATE
= "package cn.tuyucheng.taketoday.javac;\n\n" +
"public class Test {\n" +
" public static %1$s service(@Positive %1$s i) {\n" +
" return i;\n" +
" }\n" +
"}\n" +
"";
private TestCompiler compiler = new TestCompiler();
private TestRunner runner = new TestRunner();
@Test(expected = IllegalArgumentException.class)
public void givenInt_whenNegative_thenThrowsException() throws Throwable {
compileAndRun(double.class,-1);
}
private Object compileAndRun(Class<?> argumentType, Object argument)
throws Throwable {
String qualifiedClassName = "cn.tuyucheng.taketoday.javac.Test";
byte[] byteCode = compiler.compile(qualifiedClassName,
String.format(CLASS_TEMPLATE, argumentType.getName()));
return runner.run(byteCode, qualifiedClassName,
"service", new Class[] {argumentType}, argument);
}
}
在这里,我们使用service()方法编译一个测试类,该方法具有一个用@Positive标注的参数。然后,我们通过为方法参数设置double值-1来运行Test类。
作为使用我们的插件运行编译器的结果,测试将为负参数抛出IllegalArgumentException。
7. 总结
在本文中,我们展示了创建、测试和运行Java编译器插件的完整过程。
Post Directory
