Перейти к основному содержимому

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

· 9 мин. чтения

1. Обзор

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

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

2. Настройка

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

<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.foreach.javac.SampleJavacPlugin ), и поместить его в каталог META-INF/services . .

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

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

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

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

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

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

  • PARSE — строит абстрактное синтаксическое дерево (AST) .
  • ENTER — разрешен импорт исходного кода
  • ANALYZE — вывод парсера (AST) анализируется на наличие ошибок
  • GENERATE — генерация бинарников для целевого исходного файла

Есть еще два типа событий — ANNOTATION_PROCESSING и ANNOTATION_PROCESSING_ROUND , но они нас здесь не интересуют.

Например, когда мы хотим улучшить компиляцию, добавив некоторые проверки на основе информации об исходном коде, разумно сделать это в обработчике события PARSE finish :

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, через 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<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, мы добавим проверки во время выполнения для всех числовых аргументов, отмеченных аннотацией @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<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()));
}

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

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» лежит в области общедоступного API, а операции «изменения AST», такие как «добавление нулевых проверок», являются частным API .

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

Во-первых, нам нужно получить экземпляр 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) параметр).pos) .

Метод createIfCondition() строит « parameterId <, если условие:

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, подготовленный синтаксическим анализатором. Мы можем добиться этого, приведя общедоступные элементы A PI к частным типам 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 com.foreach.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.foreach.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 .