Обработка аннотаций Java и создание компоновщика

Обработка аннотаций Java и создание компоновщика

1. Вступление

Это статьяan intro to Java source-level annotation processing, в которой приведены примеры использования этого метода для создания дополнительных исходных файлов во время компиляции.

2. Приложения обработки аннотаций

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

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

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

Важно отметитьthe limitation of the annotation processing API — it can only be used to generate new files, not to change existing ones.

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

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

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

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

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

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

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

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

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

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

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


    1.0-rc2
    
      3.5.1
    




    
        com.google.auto.service
        auto-service
        ${auto-service.version}
        provided
    




    

        
            org.apache.maven.plugins
            maven-compiler-plugin
            ${maven-compiler-plugin.version}
            
                1.8
                1.8
            
        

    

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


    com.example
    annotation-processing
    1.0.0-SNAPSHOT

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

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

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 в модулеannotation-processor для методов установки. Это позволит нам сгенерировать класс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. РеализацияProcessor

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

Мы начнем с расширения классаAbstractProcessor внутри модуля Mavenannotation-processor.

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

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

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

    @Override
    public boolean process(Set annotations,
      RoundEnvironment roundEnv) {
        return false;
    }
}

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

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

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

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

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

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

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

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

@Override
public boolean process(Set annotations,
  RoundEnvironment roundEnv) {

    for (TypeElement annotation : annotations) {
        Set annotatedElements
          = roundEnv.getElementsAnnotatedWith(annotation);

        // …
    }

    return true;
}

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

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

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

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

List setters = annotatedMethods.get(true);
List 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 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 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. Запуск примера

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

Сгенерированный классPersonBuilder находится внутри файлаannotation-user/target/generated-sources/annotations/com/example/annotation/PersonBuilder.java и должен выглядеть так:

package com.example.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/example/annotation/processor/BuilderProcessor
javac com/example/annotation/processor/BuilderProperty

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

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

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

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

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

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

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

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


    

        
            org.apache.maven.plugins
            maven-compiler-plugin
            3.5.1
            
                1.8
                1.8
                UTF-8
                ${project.build.directory}
                  /generated-sources/
                
                    
                        com.example.annotation.processor.BuilderProcessor
                    
                
            
        

    

8.4. Добавление файла Processor Jar в путь к классам

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

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

com.example.annotation.processor.BuilderProcessor

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

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.example.annotation.processor.BuilderProcessor not found

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

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

8.5. Использование библиотеки Googleauto-service

Для автоматического создания файла регистрации вы можете использовать аннотацию@AutoService из библиотеки Googleauto-service, например:

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

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

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

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

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