Создать новую аннотацию в Java легко. Также легко аннотировать ею класс, метод или другой элемент. Но толку от этого кода будет мало, поскольку для любой аннотации нужен обработчик. И вот тут все становится намного сложнее, поскольку написать можно совершенно разные обработчики аннотаций, и они могут обрабатывать аннотацию на разных этапах — от компиляции до времени выполнения кода.
Есть обработчики, которые на основе аннотаций трансформируют уже скомпилированные .class файлы. Пример — обфускатор. Можно пометить своей аннотаций классы, которые не надо трогать, и написать обработчик, который опирается на аннотацию, чтобы решить, трансформировать ли класс. Другой пример — обработчик, который логирует вызов аннотированных методов, мы его написали здесь.
Есть обработчики, которые в RUNTIME меняют код с помощью библиотеки Java Reflection.
В этой статье мы рассмотрим, как обрабатывать аннотации на самом начальном уровне — в исходниках, еще до компиляции файлов.
RetentionPolicy.SOURCE
Для этого уровня достаточно, чтобы аннотация имела RetentionPolicy.SOURCE, например:
@Retention(RetentionPolicy.SOURCE) @Target(ElementType.METHOD) public @interface ToString { }
Это означает, что аннотация доступна только в исходниках, а в .class-файлах и в RUNTIME ее уже нет.
Чем полезны аннотации уровня SOURCE
Итак, чем же нам может быть полезна дополнительная обработка исходного кода?
Например, можно выполнить дополнительные проверки кода на валидность. Пример — создать аннотацию @Debug и пометить ей код, который не должен идти в продакшн. То есть надо проверять код на наличие аннотаций @Debug, и если они найдены, то прерывать компиляцию с соответствующим соообщением.
Можно на основе аннотаций генерировать дополнительный исходный код, который программисту лень писать. Пример — в JPA генерируются новые классы на основе аннотаций, программист эти классы писать не должен. В проекте Lombok генерируются сеттеры и геттеры также на основе аннотаций. (Хотя в Lombok меняются существующие классы, что является хаком. Мы рассматриваем обработчик, который предназначен прежде всего для генерации нового класса, а не изменения существующего).
Но не обязательно генерировать исходный код, можно генерировать ресурсы, код на C++, в общем — любые файлы.
Как написать свой обработчик аннотаций
Возможность написать обработчик аннотаций с помощью стандартной библиотеки Java появилась c 6-ой версии, для этого надо расширить класс
javax.annotation.processing.AbstractProcessor
В готовом виде наш обработчик — это отдельное приложение, то есть jar-файл (с определенной метаинформацией, о которой написано ниже), который передается компилятору в classpath. Если компилятор находит такой обработчик, то он запускает его в отдельной Java VM, и наш обработчик дополнительно обрабатывает исходники.
Наша задача
В этой статье мы напишем приложение, которое генерирует новый класс. Допустим, у нас есть приложение, и нем бин:
public class Cat { private String breed; private int id; private String name; public void setBreed(String breed) { this.breed = breed; } public void setId(int id) { this.id = id; } public void setName(String name) { this.name = name; } public int getId() { return id; } @ToString public String getBreed() { return breed; } @ToString public String getName() { return name; } }
Некоторые геттеры помечены аннотаций @ToString, код которой уже приводился выше.
Мы напишем второе приложение-обработчик, которое передается как параметр при компиляции первого:
javac -processor обработчик.jar
На этапе компиляции обработчик сгенерирует дополнительный класс ToStrings, содержащий статический метод toString() для бина Cat. Метод выводит информацию о бине на основе помеченных с помощью аннотацией геттеров:
public class ToStrings { public static String toString(Cat cat) { return cat.getName() + "," + cat.getBreed(); } }
Унаследуемся от AbstractProcessor
Итак, как было сказано выше, чтобы написать обработчик, надо унаследоваться от класса AbstractProcessor:
@SupportedAnnotationTypes("ru.sysout.annotationprocessing.ToString") public class AnnotationProcessor extends AbstractProcessor { @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { for (TypeElement annotation : annotations) { Set<? extends Element> annotatedElements = roundEnv.getElementsAnnotatedWith(annotation); // ... } return true; } }
В строке
@SupportedAnnotationTypes("ru.sysout.annotationprocessing.ToString")
указываются аннотации, которые поддерживает обработчик. Обработчиков может быть несколько — каждый для своих аннотаций.
Метод process()
В классе мы переопределяем метод process(). В него передаются эти аннотации в параметр Set annotations и перебираются в цикле. У нас цикл будет из одной аннотации @ToString.
Сам метод process() также вызывается неоднократно — поскольку генерируется новый исходный код, он тоже проверяется в новых раундах до тех пор, пока не закончится. (Ведь в сгенерированном коде тоже теоретически могут быть аннотации). У нас два раунда, на втором обработчик просто убеждается, что пора выходить.
Итак, что же делается в цикле:
Set<? extends Element> annotatedElements = roundEnv.getElementsAnnotatedWith(annotation); Map<Boolean, List<Element>> annotatedMethods = annotatedElements.stream().collect(Collectors.partitioningBy(element -> element.getSimpleName().toString().startsWith("get"))); List<Element> getters = annotatedMethods.get(true); List<Element> otherMethods = annotatedMethods.get(false); otherMethods.forEach(element -> processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@ToString must be applied to a getXxx method", element)); if (getters.isEmpty()) { continue; } String className = ((TypeElement) getters.get(0).getEnclosingElement()).getQualifiedName().toString(); List<String> stringGetters = getters.stream().map(getter -> getter.getSimpleName().toString()).collect(Collectors.toList()); try { writeBuilderFile(className, stringGetters); } catch (IOException e) { e.printStackTrace(); } } return true;
Мы берем все аннотированные элементы, помещаем их в Map с ключами true и false в зависимости от корректности элемента. (Мы предполагаем, что аннотироваться должны только геттеры.) Далее перекладываем элементы из Map в два списка — правильные и неправильные, для неправильных выбрасываем исключение. У нас в коде все правильные.
Генерация кода
Дальше с помощью метода writeBuilderFile() генерируем код, имея список геттеров:
private void writeBuilderFile(String className, List<String> getters) throws IOException { String packageName = null; int lastDot = className.lastIndexOf('.'); if (lastDot > 0) { packageName = className.substring(0, lastDot); } String simpleClassName = className.substring(lastDot + 1); String toStringsClassName = "ToStrings"; JavaFileObject builderFile = processingEnv.getFiler().createSourceFile(toStringsClassName); 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(toStringsClassName); out.println(" {"); out.println(); out.print(" public static String toString("+simpleClassName+" cat){"); out.println(); out.print(" return "); String result = getters.stream().map(m -> "cat." + m + "()").collect(Collectors.joining("+\",\"+")); out.println(result + ";"); out.println(" }"); out.println("}"); } }
Как видите, код, который пишет код, выглядит некрасиво — все надо записать в файл, начиная с имени пакета и не забыть фигурные скобки, но другого варианта нет.
META-INF/services/javax.annotation.processing.Processor
Для того, чтобы процессор заработал, надо поместить в папку META-INF/services файл javax.annotation.processing.Processor (да, такое длинное имя файла).
И в нем надо указать имя класса процессора:
ru.sysout.annotationprocessing.AnnotationProcessor
Можно не делать это вручную, а включить в проект библиотеку:
<dependency> <groupId>com.google.auto.service</groupId> <artifactId>auto-service</artifactId> <version>1.0-rc4</version> </dependency>
И аннотировать класс AnnotationProcessor:
... @AutoService(Processor.class) public class AnnotationProcessor extends AbstractProcessor { ... }
Тогда файл javax.annotation.processing.Processor будет создан автоматически.
Запуск примера
Сначала надо собрать приложение с процессором annotation-processing. Затем включить его в проект второго приложения annotation-use:
<dependency> <groupId>ru.sysout</groupId> <artifactId>annotation-processing</artifactId> <version>1.0-SNAPSHOT</version> </dependency>
Теперь при компиляции annotation-use в папке generated-sources/annotations появится сгенерированынй исходник класс ToStrings, и в папке classes будет в том числе он в скомпилированном виде:
Итоги
Полный код примеров доступен на GitHub:
Классный пример.
на 27 строке харкод:
out.print(» public static String toString(Cat cat){«);
должно использоваться simpleClassName полученное выше:
out.print(» public static String toString(«+simpleClassName+» cat){«);
Спасибо, исправлено!