Contents

创建Java编译器插件

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的文件,其内容是我们插件的完全限定类名 ( com.blogdemo.javac.SampleJavacPlugin ),并将其放在META-INF/services目录中.

之后,我们可以使用*-Xplugin:MyPlugin* 开关调用Javac

blogdemo/tutorials$ javac -cp ./core-java/target/classes -Xplugin:MyPlugin ./core-java/src/main/java/com/blogdemo/javac/TestClass.java
Hello from MyPlugin

请注意,我们必须始终使用从插件的getName()方法返回的String作为-Xplugin*选项值*。

3. 插件生命周期

一个*插件只被编译器调用一次,通过*init()方法。

要获得后续事件的通知,我们必须注册一个回调。这些在每个源文件的每个处理阶段之前和之后到达:

  • PARSE – 构建抽象语法树(AST)
  • ENTER - 源代码导入已解决
  • ANALYZE – 分析解析器输出(AST)的错误
  • GENERATE – 为目标源文件生成二进制文件

还有另外两种事件类型*——ANNOTATION_PROCESSINGANNOTATION_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.在哪里仪器

让我们找出如何定位应该应用仪器的目标位置:

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 中。我们可以通过将public API元素转换为private 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. 测试插件

我们需要能够测试我们的插件。它涉及以下内容:

  • 编译测试源
  • 运行已编译的二进制文件并确保它们按预期运行

为此,我们需要引入一些辅助类。

SimpleSourceFileJavac公开给定源文件的文本:

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 com.blogdemo.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 = "com.blogdemo.javac.Test";
        byte[] byteCode = compiler.compile(qualifiedClassName, 
          String.format(CLASS_TEMPLATE, argumentType.getName()));
        return runner.run(byteCode, qualifiedClassName, 
        "service", new Class[] {argumentType}, argument);
    }
}

在这里,我们正在编译一个带有service()方法的Test类,该方法有一个带有*@Positive注解的参数。然后,我们通过为方法参数设置一个双精度值 -1 来运行Test*类。

由于使用我们的插件运行编译器,测试将为负参数抛出IllegalArgumentException