Создание плагина компилятора Java

Создание плагина компилятора Java

1. обзор

Java 8 предоставляет API для создания подключаемых модулейJavac. К сожалению, по нему сложно найти хорошую документацию.

В этой статье мы покажем весь процесс создания расширения компилятора, которое добавляет пользовательский код в файлы*.class.

2. Настроить

Во-первых, нам нужно добавить JDKtools.jar в качестве зависимости для нашего проекта:


    com.sun
    tools
    1.8.0
    system
    ${java.home}/../lib/tools.jar

Every compiler extension is a class which implements com.sun.source.util.Plugin interface. Давайте создадим его в нашем примере:

Давайте создадим это в нашем примере:

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

На данный момент мы просто печатаем «Привет», чтобы убедиться, что наш код успешно получен и включен в компиляцию.

Нашей конечной целью будет создание подключаемого модуля, который добавляет проверки времени выполнения для каждого числового аргумента, отмеченного данной аннотацией, и генерирует исключение, если аргумент не соответствует условию.

Есть еще один необходимый шаг, чтобы сделать расширение доступным для обнаруженияJavac:it should be exposed through the ServiceLoader framework.

Для этого нам нужно создать файл с именемcom.sun.source.util.Plugin с содержимым, которое является полным именем класса нашего плагина (com.example.javac.SampleJavacPlugin), и поместить его в каталогMETA-INF/services.

После этого мы можем вызватьJavac с переключателем-Xplugin:MyPlugin:

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

Обратите внимание, чтоwe must always use a String returned from the plugin’s getName() method as a -Xplugin option value.

3. Жизненный цикл плагина

Aplugin is called by the compiler only once, through the init() method.

Чтобы получать уведомления о последующих событиях, мы должны зарегистрировать обратный вызов. Они поступают до и после каждого этапа обработки для исходного файла:

  • PARSE - строитAbstract Syntax Tree (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

We can get an AST generated by the Java compiler through the TaskEvent.getCompilationUnit(). Его подробности можно просмотреть через интерфейсTreeVisitor.

Обратите внимание, что только элементTree, для которого вызывается методaccept(), отправляет события данному посетителю.

Например, когда мы выполняемClassTree.accept(visitor), запускается толькоvisitClass(); мы не можем ожидать, что, скажем,visitMethod() также будет активирован для каждого метода в данном классе.

Мы можем использоватьTreeScanner для решения проблемы:

public void finished(TaskEvent e) {
    if (e.getKind() != TaskEvent.Kind.PARSE) {
        return;
    }
    e.getCompilationUnit().accept(new TreeScanner() {
        @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'");
    }
}

Это означает, что мы хотим, чтобыIllegalArgumentException выдавалось для каждого аргумента, отмеченного@Positive, который равен или меньше 0.

5.1. Где инструмент

Давайте узнаем, как мы можем определить целевые места, где следует применять инструменты:

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

Затем мы продолжим методfinished() в нашем классеSampleJavacPlugin, применив проверку ко всем параметрам, которые удовлетворяют нашим условиям:

public void finished(TaskEvent e) {
    if (e.getKind() != TaskEvent.Kind.PARSE) {
        return;
    }
    e.getCompilationUnit().accept(new TreeScanner() {
        @Override
        public Void visitMethod(MethodTree method, Void v) {
            List 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, чтобы обеспечить правильный порядок.

Поскольку каждая проверка добавляется как самая первая инструкция метода, мы обрабатываем их RTL, чтобы гарантировать правильный порядок.

5.2. Как инструмент

Проблема в том, что «чтение AST» находится в области APIpublic, а операции «изменения AST», такие как «добавление нулевых проверок», - этоprivateAPI.

Чтобы решить эту проблему,we’ll create new AST elements through a TreeMaker instance.

Во-первых, нам нужно получить экземплярContext:

@Override
public void init(JavacTask task, String... args) {
    Context context = ((BasicJavacTask) task).getContext();
    // ...
}

Затем мы можем получить объектTreeMarker с помощью методаTreeMarker.instance(Context).

Теперь мы можем создавать новые элементы AST, например, выражениеif может быть построено путем вызоваTreeMaker.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);
}

Обратите внимание, что мы хотим показать правильную линию трассировки стека, когда из нашей проверки выдается исключение. Вот почему мы настраиваем заводское положение AST перед тем, как создавать новые элементы с помощьюfactory.at(((JCTree) parameter).pos).

Метод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 в типы APIprivate:

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 {

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

Здесь мы компилируем классTest с методомservice(), который имеет параметр, помеченный@Positive.. Затем мы запускаем классTest, устанавливая двойное значение -1 для параметра метода.

В результате запуска компилятора с нашим плагином тест выдастIllegalArgumentException для отрицательного параметра.

7. Заключение

В этой статье мы показали полный процесс создания, тестирования и запуска подключаемого модуля Java Compiler.

Полный исходный код примеров можно найтиover on GitHub.