Обработка аннотаций в Java
Обработка аннотаций Java во время компиляции с помощью javax.annotation.processing для генерации кода и валидации.
Обработка аннотаций — это точка расширения в javac. Вы пишете класс — процессор аннотаций — который компилятор вызывает во время компиляции, передаёт ему обнаруженные элементы и ожидает результата. Процессор может делать две полезные вещи: валидировать аннотированный код (выдавать ошибки или предупреждения через диагностический канал javac) или записывать новые исходные файлы, которые участвуют в той же компиляции.
Фреймворки, которыми вы, вероятно, уже пользовались, работают именно на этом механизме:
- Lombok переписывает аннотированные классы, добавляя геттеры, билдеры и методы
equals/hashCode. - Dagger / Hilt генерируют связку dependency injection в ответ на
@Injectи@Module. - Статическая метамодель Hibernate генерирует классы
Entity_для типобезопасных Criteria-запросов. - Auto-Service / Auto-Value генерируют шаблонные записи
META-INFи классы-значения. - Micronaut / Quarkus генерируют связку фреймворка во время сборки, а не при запуске.
API процессора находится в javax.annotation.processing, а языковая модель — в javax.lang.model. Вместе они позволяют javac размещать сторонние инструменты времени компиляции.
Структура процессора
Процессор реализует javax.annotation.processing.Processor. На практике вы расширяете AbstractProcessor и переопределяете process(...):
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import java.util.Set;
@SupportedAnnotationTypes("com.example.Marker") // which annotations to handle
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class MarkerProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
for (Element e : roundEnv.getElementsAnnotatedWith(Marker.class)) {
processingEnv.getMessager().printMessage(
javax.tools.Diagnostic.Kind.NOTE,
"found @Marker on " + e.getSimpleName(),
e);
}
return true; // claim the annotation
}
}Две аннотации на классе объявляют, какие типы аннотаций этот процессор хочет обрабатывать и на какой языковой уровень он ориентируется. Оба значения также можно возвращать динамически из getSupportedAnnotationTypes() / getSupportedSourceVersion(), если их нужно вычислять.
process вызывается за каждый раунд. Каждый раунд — это один проход по исходным файлам; если ваш процессор создаёт новые файлы, они сами обрабатываются в следующем раунде. Цикл завершается, когда ни один раунд не создаёт новых файлов.
Языковая модель: не рефлексия
Первый сюрприз: внутри процессора у вас нет Class<?>. Классы, которые вы обрабатываете, ещё не скомпилированы. Вместо этого вы работаете с типами из javax.lang.model.element:
Element— любой элемент в исходном коде: класс, метод, поле, параметр, пакет.TypeElement— класс, интерфейс или перечисление (элементElement, у которого можно запроситьgetQualifiedName()).ExecutableElement— метод или конструктор.VariableElement— поле, параметр или локальная переменная.TypeMirror— тип (как в «типList<String>»), отличный от элемента, который его объявил.
Эти типы отражают рефлексивные типы времени выполнения, но представляют исходный код, а не загруженные классы. Вы можете обходить их, запрашивать их аннотации, запрашивать охватывающую область. Вы не можете вызывать на них методы, произвольно вычислять константные выражения или создавать их экземпляры — экземпляров ещё нет.
Чтобы прочитать значения элементов аннотации, используйте Element.getAnnotation(MyAnn.class) (возвращает прокси, аналогично рефлексии) или Element.getAnnotationMirrors() (возвращает структурное представление, которое нужно, когда значение элемента содержит ссылку на Class на тип, который тоже компилируется в этом же раунде).
Регистрация процессора
Компилятору нужно найти ваш процессор. Есть два способа:
- Файл service-loader. Поместите файл с именем
META-INF/services/javax.annotation.processing.Processorв classpath процессора, содержащий полное имя класса процессора, по одному на строку. Именно это автоматически генерируют такие инструменты, какauto-serviceот Google. - Флаг
-processor. Передайте-processor com.example.MarkerProcessorвjavac(или настройте в инструменте сборки — конфигурацияannotationProcessorв Gradle,<annotationProcessorPaths>в Maven).
В Maven и Gradle принято хранить процессор в собственном модуле и подключать его из основного модуля через annotationProcessor (Gradle) / <scope>provided</scope> (Maven). Процессор выполняется только во время компиляции и не поставляется в runtime.
Генерация файлов
Возможны два вида вывода:
- Исходные файлы — записываются через
processingEnv.getFiler().createSourceFile(name). Результат — объектJavaFileObject, в чейopenWriter()вы записываете исходный код. Новый файл компилируется в следующем раунде. - Файлы ресурсов — записываются через
getFiler().createResource(...)для всего, что попадает в classpath во время выполнения (например, регистрации сервисов).
Паттерн состоит в том, чтобы выводить пакет и имя нового класса из аннотированного элемента, а затем формировать исходный код в виде String:
TypeElement cls = ...; // the annotated class
String pkg = elementUtils.getPackageOf(cls).getQualifiedName().toString();
String genName = cls.getSimpleName() + "Generated";
JavaFileObject src = filer.createSourceFile(pkg + "." + genName, cls);
try (Writer w = src.openWriter()) {
w.write("package " + pkg + ";\n");
w.write("public class " + genName + " {\n");
w.write(" public static String origin() { return \"" + cls.getSimpleName() + "\"; }\n");
w.write("}\n");
}Реальный процессор, как правило, использует генератор кода, например JavaPoet (который предоставляет типизированный построитель AST), а не конкатенацию строк. Механика идентична; JavaPoet просто делает исходный код читаемым.
Ошибки, предупреждения, заметки
Процессор сообщает о диагностике через Messager:
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@Marker may only annotate top-level classes",
element);Kind.ERROR завершает сборку с ошибкой в указанной позиции исходного кода. WARNING, MANDATORY_WARNING и NOTE — уровни пониже. По возможности всегда передавайте аргумент Element — это даёт пользователю кликабельное местоположение в исходном коде вместо безликого блока в логе сборки.
Инкрементальная компиляция
Процессоры аннотаций — известная причина замедления сборки. Две причины:
- Они могут быть неинкрементальными: если процессору не указывается, какие источники нужно перепроцессировать, инструмент сборки перепроцессирует всё при изменении любого источника.
- Они могут блокировать параллелизм: раунды выполняются последовательно.
Gradle ввёл категории isolating и aggregating для процессоров, чтобы позволить им участвовать в инкрементальной компиляции. Процессор, создающий один сгенерированный файл на каждый аннотированный источник (Dagger делает это для @Component), может объявить себя «isolating», и Gradle перезапустит его только для изменённых источников. Агрегирующие процессоры — те, которые смотрят на все аннотированные элементы для создания единого файла реестра — перезапускаются при изменении любого аннотированного источника. Выбирайте категорию процессора честно; компромисс — это корректность против скорости.
Рабочий пример: имитация обработки аннотаций во время выполнения
Настоящая обработка аннотаций требует многомодульной сборки, точки расширения javac и файла сервиса — ничто из этого не помещается в одну программу. Лучшая демонстрация — это имитация во время выполнения, выполняющая ту же работу: обход аннотированных классов, их валидация и запись исходных файлов во временный каталог, как это делал бы процессор времени компиляции.
Что можно вынести из запуска:
- Процессор обошёл три класса и обработал два — именно так работает
RoundEnvironment.getElementsAnnotatedWith(Generate.class)в реальном процессореjavac. Третий класс был молча пропущен, поскольку его аннотация отсутствовала. Вот эта модель: процессор получает набор элементов за раунд и выполняет работу только для тех, которые его интересуют. - Каждый сгенерированный файл нёс пакет исходного класса и производное имя. В
javax.lang.modelвы вычисляете пакет изelementUtils.getPackageOf(typeElement).getQualifiedName(), а имя — изtypeElement.getSimpleName(); здесь мы использовалиClass.getPackageName()иClass.getSimpleName()как аналог. Структура переносится. - Элемент
suffixдавал настройку для каждого использования:AccountпородилAccountGenerated,Invoice—InvoiceHelper. Элементы аннотации — это главная ручка управления, которую вы предоставляете пользователю; значения по умолчанию делают типичный случай лаконичным, а именованные элементы дают точный контроль там, где он нужен. - Имитированная валидация вывела строку
ERROR:для абстрактных классов. В реальном процессоре это было быmessager.printMessage(Diagnostic.Kind.ERROR, "...", element), и сборка завершилась бы с ошибкой в позиции исходного кода пользователя. Диагностика — это полноценная функциональность, а не запасной вариант — используйте её каждый раз, когда аннотация применяется неправильно, никогда неthrow. - Сгенерированный исходный код не содержит ничего хитрого —
List.of(...)с именами полей и вспомогательный методorigin(). Это типично. Ценность генерации во время компиляции редко заключается в хитрости результата; она в том, что результат существует вообще, до запуска программы, там, где в противном случае потребовалась бы рефлексия во время выполнения (и её стоимость).
Когда стоит использовать процессор
Процессор оправдывает себя, когда:
- В противном случае вы бы писали один и тот же шаблонный код вручную для каждого аннотированного класса.
- Работу можно выполнить только по сигнатурам исходного кода (реальное поведение экземпляров не нужно).
- Альтернатива во время выполнения использовала бы рефлексию при каждом вызове, и эта стоимость накапливается.
Процессор — неправильный инструмент, когда:
- Вы хотите изменить существующий класс. Стандартные процессоры могут только добавлять новые исходные файлы; они не переписывают аннотированный класс. (Lombok переписывает, подключаясь к внутреннему AST
javac, что неофициально и ненадёжно.) - Нужные метаданные существуют только во время выполнения (область запроса, идентификация пользователя, конфигурация, загружаемая с диска).
- Простой рефлексивный поиск при запуске сделал бы то же самое в 50 строк.
Решение здесь то же, что и для любой генерации кода: больше работы во время компиляции, меньше во время выполнения, и сборка, которую сложнее отлаживать. Взвешивайте осторожно.
Конец части 16
На этом заканчивается часть книги об аннотациях. Мы рассмотрели, что такое аннотация — чистые метаданные, отдельные от кода, который выполняется, — затем небольшой набор, предоставляемый стандартной библиотекой, пять мета-аннотаций, которые настраивают ваши собственные, рецепт объявления пользовательской аннотации и, наконец, API обработки во время компиляции, который фреймворки используют для работы с аннотациями во время сборки.
Мысленная модель для дальнейшего использования: аннотация никогда ничего не делает сама по себе. Что-то другое читает её и решает действовать. Это «что-то другое» — либо компилятор (встроенные проверки), либо процессор аннотаций (генерация кода во время компиляции), либо ваш собственный код через рефлексию (фреймворки времени выполнения). Когда аннотация ведёт себя не так, как ожидается, первый вопрос всегда: кто должен её читать?
Следующая часть книги — Рефлексия — сторона API времени выполнения, которую вы уже начали использовать для чтения аннотаций.