Java Annotation Processing
Java-Annotationen zur Kompilierzeit mit javax.annotation.processing verarbeiten – Code generieren oder Quellen validieren.
Annotation Processing ist ein Erweiterungspunkt in javac. Man schreibt eine Klasse — einen Annotation Processor — den der Compiler während der Kompilierung aufruft, ihm die bisher gesehenen Elemente übergibt und auf ihn wartet. Der Processor kann zwei nützliche Dinge tun: den annotierten Code validieren (Fehler oder Warnungen über javac's Diagnosekanal ausgeben) oder neue Quelldateien schreiben, die an derselben Kompilierung teilnehmen.
Frameworks, die man wahrscheinlich bereits verwendet hat, basieren auf diesem Mechanismus:
- Lombok schreibt annotierte Klassen um und fügt Getter, Builder sowie
equals/hashCodehinzu. - Dagger / Hilt generieren Dependency-Injection-Verdrahtung als Reaktion auf
@Injectund@Module. - Hibernates statisches Metamodell generiert
Entity_-Klassen für typsichere Criteria-Abfragen. - Auto-Service / Auto-Value generieren Boilerplate-
META-INF-Service-Einträge und Value-Klassen. - Micronaut / Quarkus generieren Framework-Verdrahtung zur Buildzeit statt beim Start.
Die Processor-API liegt in javax.annotation.processing und das Sprachmodell in javax.lang.model. Zusammen ermöglichen sie es javac, Drittanbieter-Compile-Time-Tools zu hosten.
Aufbau eines Processors
Ein Processor implementiert javax.annotation.processing.Processor. In der Praxis erweitert man AbstractProcessor und überschreibt 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
}
}Die beiden Annotationen auf der Klasse deklarieren, welche Annotationstypen dieser Processor verarbeiten möchte und auf welches Sprachniveau er abzielt. Beide können auch dynamisch aus getSupportedAnnotationTypes() / getSupportedSourceVersion() zurückgegeben werden, falls eine Berechnung notwendig ist.
process wird pro Runde aufgerufen. Jede Runde ist ein Durchlauf durch die Quellen; wenn der Processor neue Dateien erzeugt, werden diese neuen Dateien selbst in einer nachfolgenden Runde verarbeitet. Die Schleife endet, wenn keine Runde mehr neue Dateien produziert.
Das Sprachmodell: kein Reflection
Die erste Überraschung: Im Inneren eines Processors gibt es kein Class<?>. Die Klassen, die verarbeitet werden, wurden noch nicht kompiliert. Stattdessen arbeitet man mit den javax.lang.model.element-Typen:
Element— alles im Quellcode: eine Klasse, Methode, Feld, Parameter, Paket.TypeElement— eine Klasse, ein Interface oder ein Enum (einElement, dasgetQualifiedName()liefert).ExecutableElement— eine Methode oder ein Konstruktor.VariableElement— ein Feld, Parameter oder eine lokale Variable.TypeMirror— ein Typ (wie „der TypList<String>"), verschieden von dem Element, das ihn deklariert hat.
Diese spiegeln die Laufzeit-Reflection-Typen wider, repräsentieren aber Quellcode, keine geladenen Klassen. Man kann sie traversieren, ihre Annotationen abfragen, ihren umgebenden Scope erfragen. Man kann keine Methoden auf ihnen aufrufen, konstante Ausdrücke beliebig auswerten oder sie instanziieren — es gibt noch keine Instanz.
Um die Element-Werte einer Annotation auszulesen, verwendet man Element.getAnnotation(MyAnn.class) (liefert einen Proxy, ähnlich wie Reflection) oder Element.getAnnotationMirrors() (liefert die strukturelle Form, die man benötigt, wenn der Element-Wert eine Class-Referenz auf einen Typ enthält, der ebenfalls in derselben Runde kompiliert wird).
Den Processor registrieren
Der Compiler muss den Processor finden. Es gibt zwei Wege:
- Service-Loader-Datei. Eine Datei namens
META-INF/services/javax.annotation.processing.Processorauf dem Classpath des Processors ablegen, deren Inhalt der vollqualifizierte Klassenname des Processors ist, einer pro Zeile. Das ist es, was Tools wie Googlesauto-serviceautomatisch generieren. -processor-Flag.-processor com.example.MarkerProcessoranjavacübergeben (oder im Build-Tool konfigurieren — GradlesannotationProcessor-Konfiguration, Mavens<annotationProcessorPaths>).
In Maven und Gradle ist es üblich, den Processor in einem eigenen Modul zu halten und ihn im Hauptmodul mit annotationProcessor (Gradle) / <scope>provided</scope> (Maven) zu referenzieren. Der Processor läuft nur während der Kompilierung und wird nicht zur Laufzeit ausgeliefert.
Dateien generieren
Zwei Ausgaben sind möglich:
- Quelldateien — geschrieben via
processingEnv.getFiler().createSourceFile(name). Das Ergebnis ist einJavaFileObject, dessenopenWriter()mit Quellcode befüllt wird. Die neue Datei wird in der nächsten Runde kompiliert. - Ressourcendateien — geschrieben via
getFiler().createResource(...)für alles, was zur Laufzeit auf dem Classpath landet (z. B. Service-Registrierungen).
Das Muster besteht darin, das Paket und den Namen der neuen Klasse aus dem annotierten Element abzuleiten und dann den Quellcode als String zu templaten:
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");
}Ein echter Processor verwendet typischerweise einen Code-Generator wie JavaPoet (der einen typisierten AST-Builder bereitstellt) statt String-Verkettung. Die Mechanik ist identisch; JavaPoet macht den Quellcode nur lesbarer.
Fehler, Warnungen, Hinweise
Ein Processor meldet Diagnosen über Messager:
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@Marker may only annotate top-level classes",
element);Kind.ERROR lässt den Build an der Quelldateiposition dieses Elements fehlschlagen. WARNING, MANDATORY_WARNING und NOTE sind die niedrigeren Stufen. Das Element-Argument sollte immer übergeben werden, wenn möglich — es gibt dem Benutzer eine klickbare Quelldateiposition statt eines Build-Log-Blobs.
Überlegungen zur inkrementellen Kompilierung
Annotation Processors sind eine bekannte Ursache für langsame Builds. Zwei Gründe:
- Sie können nicht-inkrementell sein: Wenn dem Processor nicht mitgeteilt wird, welche Quellen erneut verarbeitet werden sollen, verarbeitet das Build-Tool alles neu, sobald sich eine Quelle ändert.
- Sie können Parallelismus blockieren: Runden laufen sequenziell.
Gradle hat die Processor-Kategorien isolating und aggregating eingeführt, damit Processors an der inkrementellen Kompilierung teilnehmen können. Ein Processor, der pro annotierter Quelle eine generierte Datei produziert (Dagger macht das für @Component), kann sich als „isolating" deklarieren, und Gradle führt ihn nur für die geänderten Quellen erneut aus. Aggregating-Processors — die über alle annotierten Elemente hinwegschauen, um eine einzelne Registry-Datei zu erzeugen — werden neu gestartet, wenn sich eine annotierte Quelle ändert. Die Kategorie des Processors sollte ehrlich gewählt werden; der Kompromiss liegt zwischen Korrektheit und Geschwindigkeit.
Ein ausgearbeitetes Beispiel: ein Laufzeit-Ersatz für Compile-Time-Verarbeitung
Echtes Annotation Processing erfordert einen Multi-Modul-Build, den javac-Erweiterungspunkt und eine Service-Datei — nichts davon passt in ein einzelnes Programm. Die nächstbeste Demonstration ist ein Laufzeit-Ersatz, der dieselbe Art von Arbeit erledigt: annotierte Klassen durchlaufen, sie validieren und Quelldateien in ein temporäres Verzeichnis schreiben, wie es ein Compile-Time-Processor tun würde.
Was man aus dem Lauf mitnehmen kann:
- Der Processor hat drei Klassen durchlaufen und auf zwei reagiert — genau die Form von
RoundEnvironment.getElementsAnnotatedWith(Generate.class)in einem echtenjavac-Processor. Die dritte Klasse wurde stillschweigend übersprungen, weil ihre Annotation nicht vorhanden war. Das ist das Modell: Ein Processor verarbeitet pro Runde eine Menge von Elementen und handelt nur für die, die ihn interessieren. - Jede generierte Datei trug das Paket der Quellklasse und einen abgeleiteten Namen. In
javax.lang.modelberechnet man das Paket auselementUtils.getPackageOf(typeElement).getQualifiedName()und den Namen austypeElement.getSimpleName(); hier wurdeClass.getPackageName()undClass.getSimpleName()als Analogon verwendet. Das Muster überträgt sich. - Das
suffix-Element ermöglichte pro-Verwendungs-Anpassung:AccounterzeugteAccountGenerated,InvoiceerzeugteInvoiceHelper. Annotation-Elemente sind der Knopf, den man dem Benutzer anbietet; Standardwerte machen den häufigen Fall knapp und benannte Elemente geben präzise Kontrolle, wenn nötig. - Die simulierte Validierung gab eine
ERROR:-Zeile für abstrakte Klassen aus. In einem echten Processor wäre dasmessager.printMessage(Diagnostic.Kind.ERROR, "...", element)und der Build würde an der Quelldateiposition des Benutzers fehlschlagen. Diagnosen sind ein erstklassiges Feature, kein Fallback — sie sollten immer verwendet werden, wenn die Annotation falsch eingesetzt wird, niemalsthrow. - Der generierte Quellcode enthält nichts Kompliziertes — ein
List.of(...)mit Feldnamen und einorigin()-Hilfsmethode. Das ist typisch. Der Wert der Compile-Time-Generierung liegt selten in der Cleverness der Ausgabe; er liegt darin, dass die Ausgabe überhaupt existiert, bevor das Programm läuft, wo die Laufzeit andernfalls Reflection benötigen würde (und deren Kosten zahlen würde).
Wann man einen Processor einsetzen sollte
Ein Processor lohnt sich, wenn:
- Man anderenfalls denselben Boilerplate für jede annotierte Klasse von Hand schreiben würde.
- Die Arbeit allein aus Quell-Signaturen heraus erledigt werden kann (kein tatsächliches Instanzverhalten erforderlich).
- Die Laufzeit-Alternative bei jedem Aufruf Reflection verwenden würde, und diese Kosten sich summieren.
Ein Processor ist das falsche Werkzeug, wenn:
- Man eine bestehende Klasse modifizieren möchte. Standard-Processors können nur neue Quelldateien hinzufügen; sie schreiben die annotierte Klasse nicht um. (Lombok schreibt um, indem es sich in
javac's interne AST einklinkt, was inoffiziell und fragil ist.) - Die benötigten Metadaten nur zur Laufzeit existieren (Request-Scope, Benutzeridentität, von der Festplatte geladene Konfiguration).
- Ein einfaches reflektives Lookup beim Start dieselbe Arbeit in 50 Zeilen erledigen würde.
Die Entscheidung ist dieselbe wie bei jeder Code-Generierung: mehr Arbeit zur Kompilierzeit, weniger Arbeit zur Laufzeit und ein Build, der schwieriger zu debuggen ist. Sorgfältig abwägen.
Ende von Teil 16
Damit schließt der Annotations-Teil des Buches ab. Behandelt wurden: was eine Annotation ist — reine Metadaten, verschieden von Code, der ausgeführt wird — dann die kleine Menge, die die Standardbibliothek bereitstellt, die fünf Meta-Annotationen, die eigene konfigurieren, das Rezept zum Deklarieren einer eigenen Annotation und schließlich die Compile-Time-Processing-API, die Frameworks nutzen, um Annotationen während des Builds zu verarbeiten.
Das mentale Modell, das man mitnehmen sollte: Eine Annotation tut nie etwas. Etwas anderes liest sie und entscheidet sich zu handeln. Dieses „etwas anderes" ist entweder der Compiler (eingebaute Lints), ein Annotation Processor (Compile-Time-Codegenerierung) oder eigener Code via Reflection (Laufzeit-Frameworks). Wenn eine Annotation nicht wie erwartet funktioniert, lautet die erste Frage immer: Wer soll sie eigentlich lesen?
Der nächste Teil des Buches ist Reflection — die Laufzeitseite der API, die man bereits zum Lesen von Annotationen verwendet hat.