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

Обработка аннотаций Java и создание построителя

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

Задача: Наибольшая подстрока без повторений

Для заданной строки s, найдите длину наибольшей подстроки без повторяющихся символов. Подстрока — это непрерывная непустая последовательность символов внутри строки...

ANDROMEDA 42

1. Введение

Эта статья представляет собой введение в обработку аннотаций на уровне исходного кода Java и содержит примеры использования этого метода для создания дополнительных исходных файлов во время компиляции.

2. Применение обработки аннотаций

Обработка аннотаций на уровне исходного кода впервые появилась в Java 5. Это удобный метод создания дополнительных исходных файлов на этапе компиляции.

Исходные файлы не обязательно должны быть файлами Java — вы можете создавать любые описания, метаданные, документацию, ресурсы или файлы любого другого типа на основе аннотаций в исходном коде.

Обработка аннотаций активно используется во многих вездесущих библиотеках Java, например, для генерации метаклассов в QueryDSL и JPA, для дополнения классов шаблонным кодом в библиотеке Lombok.

Важно отметить ограничение API обработки аннотаций — его можно использовать только для создания новых файлов, а не для изменения существующих .

Заметным исключением является библиотека Lombok , которая использует обработку аннотаций в качестве механизма начальной загрузки, чтобы включить себя в процесс компиляции и изменить AST через некоторые внутренние API-интерфейсы компилятора. Этот хакерский метод не имеет ничего общего с предполагаемой целью обработки аннотаций и поэтому не обсуждается в этой статье.

3. API обработки аннотаций

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

Если во время этого процесса генерируются какие-либо файлы, запускается другой раунд с генерируемыми файлами в качестве входных данных. Этот процесс продолжается до тех пор, пока на этапе обработки не будут созданы новые файлы.

Каждый процессор аннотаций, в свою очередь, обращается к соответствующим источникам. Если во время этого процесса генерируются какие-либо файлы, запускается другой раунд с генерируемыми файлами в качестве входных данных. Этот процесс продолжается до тех пор, пока на этапе обработки не будут созданы новые файлы.

API обработки аннотаций находится в пакете javax.annotation.processing . Основной интерфейс, который вам нужно будет реализовать, — это интерфейс Processor , который имеет частичную реализацию в виде класса AbstractProcessor . Этот класс мы собираемся расширить, чтобы создать собственный процессор аннотаций.

4. Настройка проекта

Чтобы продемонстрировать возможности обработки аннотаций, мы разработаем простой процессор для создания быстрых компоновщиков объектов для аннотированных классов.

Мы собираемся разделить наш проект на два модуля Maven. Один из них, модуль annotation-processor , будет содержать сам процессор вместе с аннотацией, а другой, модуль annotation-user , будет содержать аннотированный класс. Это типичный пример использования обработки аннотаций.

Настройки модуля обработки аннотаций следующие. Мы собираемся использовать библиотеку автоматического обслуживания Google для создания файла метаданных процессора, который будет обсуждаться позже, и плагин maven-compiler-plugin, настроенный для исходного кода Java 8. Версии этих зависимостей извлекаются в раздел свойств.

Последние версии библиотеки автоматического обслуживания и плагина maven-compiler-plugin можно найти в репозитории Maven Central:

<properties>
<auto-service.version>1.0-rc2</auto-service.version>
<maven-compiler-plugin.version>
3.5.1
</maven-compiler-plugin.version>
</properties>

<dependencies>

<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>${auto-service.version}</version>
<scope>provided</scope>
</dependency>

</dependencies>

<build>
<plugins>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>

</plugins>
</build>

Модуль Maven annotation-user с аннотированными исходниками не нуждается в какой-либо специальной настройке, кроме добавления зависимости от модуля annotation-processor в разделе зависимостей:

<dependency>
<groupId>com.foreach</groupId>
<artifactId>annotation-processing</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>

5. Определение аннотации

Предположим, у нас есть простой класс POJO в нашем модуле пользователя-аннотации с несколькими полями:

public class Person {

private int age;

private String name;

// getters and setters …

}

Мы хотим создать вспомогательный класс построителя, чтобы более свободно создавать экземпляры класса Person :

Person person = new PersonBuilder()
.setAge(25)
.setName("John")
.build();

Этот класс PersonBuilder является очевидным выбором для поколения, поскольку его структура полностью определяется методами установки Person .

Давайте создадим аннотацию @BuilderProperty в модуле обработчика аннотаций для методов установки. Это позволит нам сгенерировать класс Builder для каждого класса, у которого есть аннотированные методы установки:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface BuilderProperty {
}

Аннотация @Target с параметром ElementType.METHOD гарантирует, что эту аннотацию можно поместить только в метод.

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

Класс Person со свойствами, аннотированными аннотацией @BuilderProperty , будет выглядеть следующим образом:

public class Person {

private int age;

private String name;

@BuilderProperty
public void setAge(int age) {
this.age = age;
}

@BuilderProperty
public void setName(String name) {
this.name = name;
}

// getters …

}

6. Реализация процессора

6.1. Создание подкласса AbstractProcessor

Мы начнем с расширения класса AbstractProcessor внутри модуля Maven процессора аннотаций .

Во-первых, мы должны указать аннотации, которые этот процессор способен обрабатывать, а также поддерживаемую версию исходного кода. Это можно сделать либо путем реализации методов getSupportedAnnotationTypes и getSupportedSourceVersion интерфейса процессора , либо путем аннотирования класса аннотациями @SupportedAnnotationTypes и @SupportedSourceVersion .

Аннотация @AutoService является частью библиотеки автоматического обслуживания и позволяет генерировать метаданные процессора, которые будут объяснены в следующих разделах.

@SupportedAnnotationTypes(
"com.foreach.annotation.processor.BuilderProperty")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@AutoService(Processor.class)
public class BuilderProcessor extends AbstractProcessor {

@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
return false;
}
}

Вы можете указать не только конкретные имена классов аннотаций, но и подстановочные знаки, такие как «com.foreach.annotation.*» для обработки аннотаций внутри пакета com.foreach.annotation и всех его подпакетов, или даже «*» для обработки всех аннотаций . .

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

Аннотации передаются как первый Set<? расширяет аргумент аннотации TypeElement> , а информация о текущем раунде обработки передается в качестве аргумента RoundEnviroment roundEnv .

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

6.2. Сбор данных

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

Во-первых, нам нужно перебрать все типы аннотаций, которые находятся в классе — в нашем случае набор аннотаций будет иметь единственный элемент, соответствующий аннотации @BuilderProperty , даже если эта аннотация встречается несколько раз в исходном файле.

Тем не менее, для полноты картины лучше реализовать метод процесса как итерационный цикл:

@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {

for (TypeElement annotation : annotations) {
Set<? extends Element> annotatedElements
= roundEnv.getElementsAnnotatedWith(annotation);

// …
}

return true;
}

В этом коде мы используем экземпляр RoundEnvironment для получения всех элементов, аннотированных аннотацией @BuilderProperty . В случае класса Person эти элементы соответствуют методам setName и setAge .

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

В следующем коде мы используем сборщик Collectors.partitioningBy() для разделения аннотированных методов на две коллекции: правильно аннотированные сеттеры и другие ошибочно аннотированные методы:

Map<Boolean, List<Element>> annotatedMethods = annotatedElements.stream().collect(
Collectors.partitioningBy(element ->
((ExecutableType) element.asType()).getParameterTypes().size() == 1
&& element.getSimpleName().toString().startsWith("set")));

List<Element> setters = annotatedMethods.get(true);
List<Element> otherMethods = annotatedMethods.get(false);

Здесь мы используем метод Element.asType() для получения экземпляра класса TypeMirror , который дает нам некоторую возможность интроспекции типов, даже если мы находимся только на этапе обработки исходного кода.

Мы должны предупредить пользователя о неправильно аннотированных методах, поэтому воспользуемся экземпляром Messager , доступным из защищенного поля AbstractProcessor.processingEnv . Следующие строки будут выводить ошибку для каждого ошибочно аннотированного элемента на этапе обработки исходного кода:

otherMethods.forEach(element ->
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
"@BuilderProperty must be applied to a setXxx method "
+ "with a single argument", element));

Конечно, если правильная коллекция сеттеров пуста, нет смысла продолжать текущую итерацию набора элементов типа:

if (setters.isEmpty()) {
continue;
}

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

String className = ((TypeElement) setters.get(0)
.getEnclosingElement()).getQualifiedName().toString();

Последний бит информации, который нам нужен для создания класса построителя, — это сопоставление между именами сеттеров и именами их типов аргументов:

Map<String, String> setterMap = setters.stream().collect(Collectors.toMap(
setter -> setter.getSimpleName().toString(),
setter -> ((ExecutableType) setter.asType())
.getParameterTypes().get(0).toString()
));

6.3. Создание выходного файла

Теперь у нас есть вся информация, необходимая для создания класса построителя: имя исходного класса, все имена его сеттеров и их типы аргументов.

Чтобы сгенерировать выходной файл, мы будем использовать экземпляр Filer , снова предоставленный объектом в защищенном свойстве AbstractProcessor.processingEnv :

JavaFileObject builderFile = processingEnv.getFiler()
.createSourceFile(builderClassName);
try (PrintWriter out = new PrintWriter(builderFile.openWriter())) {
// writing generated file to out …
}

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

private void writeBuilderFile(
String className, Map<String, String> setterMap)
throws IOException {

String packageName = null;
int lastDot = className.lastIndexOf('.');
if (lastDot > 0) {
packageName = className.substring(0, lastDot);
}

String simpleClassName = className.substring(lastDot + 1);
String builderClassName = className + "Builder";
String builderSimpleClassName = builderClassName
.substring(lastDot + 1);

JavaFileObject builderFile = processingEnv.getFiler()
.createSourceFile(builderClassName);

try (PrintWriter out = new PrintWriter(builderFile.openWriter())) {

if (packageName != null) {
out.print("package ");
out.print(packageName);
out.println(";");
out.println();
}

out.print("public class ");
out.print(builderSimpleClassName);
out.println(" {");
out.println();

out.print(" private ");
out.print(simpleClassName);
out.print(" object = new ");
out.print(simpleClassName);
out.println("();");
out.println();

out.print(" public ");
out.print(simpleClassName);
out.println(" build() {");
out.println(" return object;");
out.println(" }");
out.println();

setterMap.entrySet().forEach(setter -> {
String methodName = setter.getKey();
String argumentType = setter.getValue();

out.print(" public ");
out.print(builderSimpleClassName);
out.print(" ");
out.print(methodName);

out.print("(");

out.print(argumentType);
out.println(" value) {");
out.print(" object.");
out.print(methodName);
out.println("(value);");
out.println(" return this;");
out.println(" }");
out.println();
});

out.println("}");
}
}

7. Запуск примера

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

Сгенерированный класс PersonBuilder можно найти в файле annotation-user/target/generated-sources/annotations/com/foreach/annotation/PersonBuilder.java , и он должен выглядеть следующим образом:

package com.foreach.annotation;

public class PersonBuilder {

private Person object = new Person();

public Person build() {
return object;
}

public PersonBuilder setName(java.lang.String value) {
object.setName(value);
return this;
}

public PersonBuilder setAge(int value) {
object.setAge(value);
return this;
}
}

8. Альтернативные способы регистрации процессора

Чтобы использовать процессор аннотаций на этапе компиляции, у вас есть несколько других вариантов, в зависимости от вашего варианта использования и инструментов, которые вы используете.

8.1. Использование инструмента обработки аннотаций

Инструмент apt представлял собой специальную утилиту командной строки для обработки исходных файлов. Он был частью Java 5, но начиная с Java 7 он был объявлен устаревшим в пользу других опций и полностью удален в Java 8. В этой статье он обсуждаться не будет.

8.2. Использование ключа компилятора

Ключ компилятора -processor — это стандартное средство JDK для расширения стадии обработки исходного кода компилятора с помощью вашего собственного процессора аннотаций.

Обратите внимание, что сам процессор и аннотация должны быть уже скомпилированы как классы в отдельной компиляции и представлены в пути к классам, поэтому первое, что вы должны сделать, это:

javac com/foreach/annotation/processor/BuilderProcessor
javac com/foreach/annotation/processor/BuilderProperty

Затем вы выполняете фактическую компиляцию своих исходников с ключом -processor , указывающим класс процессора аннотаций, который вы только что скомпилировали:

javac -processor com.foreach.annotation.processor.MyProcessor Person.java

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

javac -processor package1.Processor1,package2.Processor2 SourceFile.java

8.3. Использование Maven

Плагин maven-compiler-plugin позволяет указывать процессоры аннотаций как часть его конфигурации.

Вот пример добавления обработчика аннотаций для плагина компилятора. Вы также можете указать каталог для размещения сгенерированных источников, используя параметр конфигурации generateSourcesDirectory .

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

<build>
<plugins>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
<generatedSourcesDirectory>${project.build.directory}
/generated-sources/</generatedSourcesDirectory>
<annotationProcessors>
<annotationProcessor>
com.foreach.annotation.processor.BuilderProcessor
</annotationProcessor>
</annotationProcessors>
</configuration>
</plugin>

</plugins>
</build>

8.4. Добавление JAR-файла процессора в путь к классам

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

Чтобы подобрать его автоматически, компилятор должен знать имя класса процессора. Поэтому вы должны указать его в файле META-INF/services/javax.annotation.processing.Processor как полное имя класса процессора:

com.foreach.annotation.processor.BuilderProcessor

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

package1.Processor1
package2.Processor2
package3.Processor3

Если вы используете Maven для сборки этого jar-файла и попытаетесь поместить этот файл непосредственно в каталог src/main/resources/META-INF/services , вы столкнетесь со следующей ошибкой:

[ERROR] Bad service configuration file, or exception thrown while 
constructing Processor object: javax.annotation.processing.Processor:
Provider com.foreach.annotation.processor.BuilderProcessor not found

Это связано с тем, что компилятор пытается использовать этот файл на этапе обработки исходного кода самого модуля, когда файл BuilderProcessor еще не скомпилирован. Файл должен быть либо помещен в другой каталог ресурсов и скопирован в каталог META-INF/services на этапе копирования ресурсов сборки Maven, либо (что еще лучше) сгенерирован во время сборки.

Библиотека автосервиса Google , обсуждаемая в следующем разделе, позволяет создать этот файл с помощью простой аннотации.

8.5. Использование библиотеки автосервисов Google

Чтобы сгенерировать файл регистрации автоматически, вы можете использовать аннотацию @AutoService из библиотеки автосервисов Google , например:

@AutoService(Processor.class)
public BuilderProcessor extends AbstractProcessor {
// …
}

Сама эта аннотация обрабатывается процессором аннотаций из библиотеки автосервиса. Этот процессор создает файл META-INF/services/javax.annotation.processing.Processor , содержащий имя класса BuilderProcessor .

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

В этой статье мы продемонстрировали обработку аннотаций на уровне исходного кода на примере создания класса Builder для POJO. Мы также предоставили несколько альтернативных способов регистрации обработчиков аннотаций в вашем проекте.

Исходный код статьи доступен на GitHub .