Аннотации в Java. Как создать свою аннотацию

Объясняю на пальцах, что такое аннотации в Java, а также рассказываю как создать свою аннотацию и обработчик к ней в Java.

· 6 минуты на чтение
Аннотации в Java. Как создать свою аннотацию

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

Вы узнаете аннотацию по символу @ в начале имени. Самая часто встречаемая аннотация, которую встречал любой программист это @Override. Эта аннотация сообщает компилятору, что мы переопределили метод. Поэтому, когда метод суперкласса будет удален или изменен, компилятор выдаст сообщение об ошибке. Рассмотрим небольшой пример:

class SomeClass {
    void method() {
        System.out.println("Работает метод родительского класса.");
    }
}

class AnotherClass extends SomeClass { // наследуем методы SomeClass в новом классе
    @Override
    void method() { // переопределяем метод
        System.out.println("Работает метод класса-потомка.");
    }
}

Если в имени метода из класса AnotherClass будет опечатка, компилятор учтет @Override и выдаст ошибку. Без аннотации он не заметил бы подвоха и создал бы новый метод в дополнение к method из SomeClass.

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

Спонсор поста

Структура аннотации

Создание аннотаций очень похоже на создание интерфейса, только вот само ключевое слово interface пишется со знаком @.

public @interface MyAnnotation {
    String name() default "";
    int value();
}

Параметры задаются как методы у интерфейсов, только без аргументов. А ключевое слово default — говорит про то, что метод по умолчанию будет возвращать определённое значение.

Так как мы не сконфигурировали аннотацию, то она может применяться к чему угодно: к классам, методам, атрибутам и т. п. Для того чтобы ограничить использование аннотации, её нужно разметить аннотациями 😄

@Target(ElementType.TYPE)
public @interface MyAnnotation {
    ...
}

Аннотация @Target позволяет ограничить область применения:

  • @Target(ElementType.PACKAGE) – только для пакетов;
  • @Target(ElementType.TYPE) – только для классов;
  • @Target(ElementType.CONSTRUCTOR) – только для конструкторов;
  • @Target(ElementType.METHOD) – только для методов;
  • @Target(ElementType.FIELD) – только для атрибутов(переменных) класса;
  • @Target(ElementType.PARAMETER) – только для параметров метода;
  • @Target(ElementType.LOCAL_VARIABLE) – только для локальных переменных;
  • @Target(ElementType.ANNOTATION_TYPE) - означает аннотацию конфигурацию. Таким образом, аннотация может использоваться только для аннотирования других аннотаций. Как @Target и @Retention.

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

@Target({ ElementType.PARAMETER, ElementType.LOCAL_VARIABLE })

Помимо @Target есть еще несколько аннотаций, для настройки:

@Retention определяет в каком жизненном цикле кода аннотация будет доступна.

  • SOURCE - аннотация доступна только в исходном коде и стирается во время создания .class файла;
  • CLASS - аннотация хранится и в .class файле, но недоступна во время выполнения программы;
  • RUNTIME - аннотация хранится в .class файле и доступна во время выполнения программы.

@Inherited позволяет реализовать наследование аннотаций родительского класса классом-наследником

@Inherited
public @interface MyAnnotation { }

@MyAnnotation
public class MySuperClass { ... }

public class MySubClass extends MySuperClass { ... }

В этом примере класс MySubClass наследует аннотацию @MyAnnotation, потому что MySubClass наследуется от MySuperClass, а MySuperClass имеет @MyAnnotation.

@Documented - аннотация будет помещена в сгенерированную документацию javadoc

Обработчик аннотации

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

😺
Проект на GitHub: creating-annotation

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

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

public class Simple {

    private String text;
    private Integer number;
    private Long numberTwo;

}

А наша аннотация должна сгенерировать нам класс в том же пакете с названием SimpleFields:

public class SimpleFields {

    public final static String TEXT = "text";
    public final static String NUMBER = "number";
    public final static String NUMBER_TWO = "numberTwo";

}

Для этого создаем аннотацию @FieldNames:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)
public @interface FieldNames {

    String postfix() default "Fields";

}

Параметр postfix будет отвечать за окончание названия сгенерированного класса. По умолчанию будет к названию класса добавляется Fields.

Самое простое позади, теперь создадим обработчик FieldNameProcessor, который наследуется от AbstractProcessor.

@SupportedAnnotationTypes("org.sadtech.example.annotation.FieldNames")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
@AutoService(Processor.class)
public class FieldNameProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set set, RoundEnvironment roundEnvironment) {
        return false;
    }

}

Аннотация @SupportedAnnotationTypes отвечает за указание аннотации для которой этот обработчик создается.

Аннотация @AutoService упрощает создание манифеста. Но для нее нужно добавить новую зависимость

<dependency>
    <groupId>com.google.auto.service</groupId>
    <artifactId>auto-service</artifactId>
    <version>1.0.1</version>
    <scope>provided</scope>
</dependency>
Актуальная версия в Maven Central

Как вы можете видеть нам необходимо реализовать метод process.

Дебажить обработчик обычным способом у вас не получится. Как дебажить обработчики я писал в отдельной статье: Дебаг приложения на этапе компиляции IntelliJ IDEA

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

@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
    for (TypeElement annotation : set) {
        Set<? extends Element> annotatedElements = roundEnvironment.getElementsAnnotatedWith(annotation);
        for (Element annotatedElement : annotatedElements) {
            // тут будет логика обработки
        }
    }
    return true;
}

Осталось написать логику обработки нашей аннотации. Логика будет такая:

  • Получить необходимую информацию аннотированного класса:
    • Имя класса
    • Имя пакета
    • Массив полей
  • Сложим это информацию в обычный POJO класс
  • На основании этого класса создадим новый класс.

Создадим 2 новых класса. ClassDto будет содержать информацию, необходимую для генерации нового класса. Класс FieldDto будет отвечать за информацию необходимую для создания public static final String полей. Лучше смотреть на примерах, так сложно объяснить.

public class ClassDto {

    private String className;
    private String classPackage;
    private Set fields;

    // getters and setters

}
public class FieldDto {

    private final String fieldStringName;
    private final String fieldName;

    private FieldDto(String fieldStringName, String fieldName) {
        this.fieldStringName = fieldStringName;
        this.fieldName = fieldName;
    }

    public static FieldDto of(String fieldStringName, String fieldName) {
        return new FieldDto(fieldStringName, fieldName);
    }

    // getters

}

Чтобы преобразовать имя переменной numberTwo в имя статической переменной NUMBER_TWO нам понадобиться еще одна зависисомость:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.0.1-jre</version>
</dependency>
Актуальная версия в Maven Central

Теперь нам надо заполнить класс ClassDto информацией:

@Override
public boolean process(Set set, RoundEnvironment roundEnvironment) {
    for (TypeElement annotation : set) {
        Set annotatedElements = roundEnvironment.getElementsAnnotatedWith(annotation);
        for (Element annotatedElement : annotatedElements) {
            final TypeMirror mirror = annotatedElement.asType();
            final String annotatedElementName = annotatedElement.getSimpleName().toString();
            final FieldNames settings = annotatedElement.getAnnotation(FieldNames.class);
            final String newClassName = annotatedElementName + settings.postfix();

            final Set fields = annotatedElement.getEnclosedElements().stream()
                    .filter(this::isField)
                    .map(
                            element -> {
                                final String fieldName = element.getSimpleName().toString();
                                final String fieldStringName = CaseFormat.LOWER_CAMEL.to(CaseFormat.UPPER_UNDERSCORE, fieldName);
                                return FieldDto.of(fieldStringName, fieldName);
                            }
                    ).collect(Collectors.toSet());

            final ClassDto newClass = new ClassDto();
            newClass.setClassName(newClassName);
            newClass.setFields(fields);
            newClass.setClassPackage(getPackage(mirror));
            ClassCreator.record(newClass, processingEnv);
        }
    }
    return true;
}

public boolean isField(Element element) {
    return element != null && element.getKind().isField();
}

public static String getPackage(TypeMirror typeMirror) {
    final String[] split = typeMirror.toString().split("\\.");
    return String.join(".", Arrays.copyOf(split, split.length - 1));
}
  • Строка 6: Переменная TypeMirror mirror позволит нам в дальнейшем получить пакет аннотированного класса.
  • Строка 7: annotatedElementName это имя аннотированного класса. Потом в строке 9 мы к нему добавляем наш postfix.
  • 8: Мы получаем нашу аннотацию с параметрами настройки.
  • 11-19: Проходим по всем элементам аннотированного класса, находим только поля и преобразуем их в FieldDto.
  • 21-24: Складываем полученную информацию в новый класс ClassDto
  • 25: Предаем созданный класс в метод, который сгенерирует нам новый класс EntityNameFields. Обратите внимание, что мы так же передаем переменную processingEnv, но нигде ее не создаем. Эта переменная класса AbstractProcessor, от которого мы наследовали наш класс обработчик. Эта переменная поможет нам создать новый класс.

Рассмотрим класс генератор:

public class ClassCreator {

    private ClassCreator() {

    }

    public static void record(ClassDto classDto, ProcessingEnvironment environment) {
        JavaFileObject builderFile = null;
        try {
            builderFile = environment.getFiler().createSourceFile(classDto.getClassName());
        } catch (IOException e) {
            e.printStackTrace();
        }

        try (PrintWriter out = new PrintWriter(builderFile.openWriter())) {
            out.println("package " + classDto.getClassPackage() + ";");
            out.println();
            out.print("public class " + classDto.getClassName() + " {");
            out.println();
            out.println();
            generateNames(classDto.getFields(), out);
            out.println();
            out.println("}");
            out.println();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private static void generateNames(Set fields, PrintWriter out) {
        for (FieldDto field : fields) {
            out.println("   public static final String " + field.getFieldStringName() + " = \"" + field.getFieldName() + "\";");
        }
    }

}
  • 8-13: Создают непосредственно физический файл формата .java.
  • 15-34: Идет заполнение созданного файла данными.
    • 16: Записываем имя пакета
    • 18,23: Создаем пустой класс
    • 21,30-31: Заполняем класс статическими переменными с именами полей аннотированного класса.

Проверка работы

😺
Проект на GitHub: Example-uPagge/use-annotation

Создадим новый проект, чтобы проверить работу нашей аннотации. В pom.xml указываем зависимость на нашу библиотеку.

<dependency>
    <groupId>dev.struchkov.example</groupId>
    <artifactId>create-annotation</artifactId>
    <version>0.0.2-SNAPSHOT</version>
</dependency>
Не забудьте сначала собрать этот проект используя mvn clean install

Создадим там класс TestEntity, которую пометим нашей аннотацией.

@FieldNames
public class TestEntity {

    private Long id;
    private String title;
    private String phoneNumber;

}

Теперь необходимо зарегистрировать обработчик аннотации в плагине maven-compiler:

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.9.0</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>dev.struchkov.example</groupId>
                            <artifactId>create-annotation</artifactId>
                            <version>0.0.2-SNAPSHOT</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
        </plugins>
    </build>

Теперь запускаем ребилд проекта: “Build” -> “Rebuild Project”. Или можете запустить команду мавена: mvn clean compile.

Заходим в папку target/generated-sources/annotations. И видим там наш сгенерированный класс:

public class TestEntityFields {

    public static final String ID = "id";
    public static final String TITLE = "title";
    public static final String PHONE_NUMBER = "phoneNumber";

}

Заключение

Мы разобрались что такое аннотация и как она выглядит. Также мы научились создавать свои собственные аннотации и обработчики к ним.

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇