Рубрики
Без рубрики

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

Узнайте, как можно создать плагин компилятора Java.

Автор оригинала: baeldung.

1. Обзор

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

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

2. Настройка

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


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

Каждое расширение компилятора-это класс, реализующий com.sun.source.util.Плагин интерфейс. Давайте создадим его в нашем примере:

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

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: оно должно быть открыто через фреймворк ServiceLoader .

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

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

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

Обратите внимание, что мы всегда должны использовать Строку , возвращаемую из метода plugin getName() в качестве -Plugin значения параметра .

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

Плагин вызывается компилятором только один раз, через метод init () .

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

  • PARSE – строит Абстрактное синтаксическое дерево (AST)
  • ВВЕДИТЕ – импорт исходного кода разрешен
  • 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

Мы можем получить AST, сгенерированный компилятором Java, с помощью Task Event.getCompilationUnit() . Его детали можно изучить через интерфейс Посетитель дерева|/.

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

Например, когда мы выполняем Class Tree.accept(visitor) , запускается только visit Class () ; мы не можем ожидать, что, скажем, 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.visit Xxx(узел, значение) для рекурсивной обработки дочерних элементов текущего узла.

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 быть брошенным для каждого аргумента, отмеченного @Позитив который равен или меньше 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() в нашем Примере плагина Javac класса с применением проверки ко всем параметрам, которые удовлетворяют нашим условиям:

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, чтобы обеспечить правильный порядок.

5.2. Как использовать инструмент

Проблема в том, что “АКТ чтения” лежит в области public API, в то время как “изменение ЕГО” операции, такие как “добавление нулевых проверок”, являются частными API .

Чтобы решить эту проблему, мы создадим новые элементы AST через экземпляр Tree Maker .

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

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

Затем мы можем получить маркер дерева объекта с помощью метода Treemaker.instance(Контекст) .

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

Обратите внимание, что мы хотим показать правильную строку трассировки стека при возникновении исключения из нашей проверки. Вот почему мы корректируем положение ASTfactory, прежде чем создавать через него новые элементы с помощью factory.at (((параметр JCTree)). 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));
}

Затем метод create If Block() создает блок, который возвращает исключение 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, нам нужно вставить их в АКТ, подготовленный анализатором. Мы можем достичь этого, приведя public A PI элементы к 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. Тестирование плагина

Нам нужно иметь возможность протестировать наш плагин. Она включает в себя следующее:

  • скомпилируйте исходный код теста
  • запустите скомпилированные двоичные файлы и убедитесь, что они ведут себя так, как ожидалось

Для этого нам нужно ввести несколько вспомогательных классов.

Простой исходный файл предоставляет текст данного исходного файла в 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;
    }
}

Простой файл класса содержит результат компиляции в виде массива байтов:

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
}

Простой файловый менеджер гарантирует, что компилятор использует наш держатель байт-кода:

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.baeldung.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.baeldung.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.

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