Elaborazione delle annotazioni in Java
Elabora le annotazioni Java in fase di compilazione con l'API javax.annotation.processing per generare codice o validare sorgenti.
L'elaborazione delle annotazioni è un punto di estensione in javac. Si scrive una classe — un annotation processor — che il compilatore chiama durante la compilazione, passandogli gli elementi che ha trovato fino a quel momento e attendendo il completamento. Il processor può fare due cose utili: validare il codice annotato (emettere errori o avvisi tramite il canale diagnostico di javac) oppure scrivere nuovi file sorgente che partecipano alla stessa compilazione.
I framework che probabilmente hai già usato sono alimentati da questo meccanismo:
- Lombok riscrive le classi annotate per aggiungere getter, builder e
equals/hashCode. - Dagger / Hilt genera il cablaggio per l'iniezione delle dipendenze in risposta a
@Injecte@Module. - Il metamodello statico di Hibernate genera classi
Entity_per query Criteria type-safe. - Auto-Service / Auto-Value generano entry boilerplate in
META-INFe classi di valore. - Micronaut / Quarkus generano il cablaggio del framework in fase di build anziché all'avvio.
L'API del processor si trova in javax.annotation.processing e il modello del linguaggio in javax.lang.model. Insieme consentono a javac di ospitare strumenti di terze parti per il tempo di compilazione.
La struttura di un processor
Un processor implementa javax.annotation.processing.Processor. In pratica si estende AbstractProcessor e si sovrascrive 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
}
}Le due annotazioni sulla classe dichiarano quali tipi di annotazione questo processor vuole gestire e quale livello linguistico ha come target. Entrambi possono anche essere restituiti dinamicamente da getSupportedAnnotationTypes() / getSupportedSourceVersion() se è necessario calcolarli.
process viene chiamato per ogni round. Ogni round è un passaggio attraverso i sorgenti; se il processor produce nuovi file, questi vengono elaborati in un round successivo. Il ciclo termina quando nessun round produce nuovi file.
Il modello del linguaggio: non è reflection
La prima sorpresa: all'interno di un processor non si ha Class<?>. Le classi che si stanno elaborando non sono ancora state compilate. Si lavora invece con i tipi di javax.lang.model.element:
Element— qualsiasi cosa nel sorgente: una classe, un metodo, un campo, un parametro, un pacchetto.TypeElement— una classe, interfaccia o enum (unElementa cui si può chiederegetQualifiedName()).ExecutableElement— un metodo o un costruttore.VariableElement— un campo, parametro o variabile locale.TypeMirror— un tipo (come "il tipoList<String>"), distinto dall'elemento che lo ha dichiarato.
Questi rispecchiano i tipi della reflection a runtime ma rappresentano il sorgente, non le classi caricate. È possibile scorrerli, chiedere le loro annotazioni, chiedere il loro scope di appartenenza. Non è possibile invocare metodi su di essi, valutare espressioni costanti arbitrariamente o istanziarli — non esiste ancora alcuna istanza.
Per leggere i valori degli elementi di un'annotazione si usa Element.getAnnotation(MyAnn.class) (restituisce un proxy, simile alla reflection) oppure Element.getAnnotationMirrors() (restituisce la forma strutturale, necessaria quando il valore dell'elemento contiene un riferimento a un tipo Class che è anch'esso in fase di compilazione nello stesso round).
Registrazione del processor
Il compilatore deve trovare il processor. Ci sono due modi:
- File service-loader. Inserire un file denominato
META-INF/services/javax.annotation.processing.Processornel classpath del processor, il cui contenuto è il nome completo della classe del processor, uno per riga. È quello che strumenti comeauto-servicedi Google generano automaticamente. - Flag
-processor. Passare-processor com.example.MarkerProcessorajavac(o configurarlo nel build tool — la configurazioneannotationProcessordi Gradle,<annotationProcessorPaths>di Maven).
In Maven e Gradle la convenienza è mantenere il processor nel proprio modulo e dipenderne dal modulo principale con annotationProcessor (Gradle) / <scope>provided</scope> (Maven). Il processor viene eseguito solo durante la compilazione e non viene incluso nella distribuzione a runtime.
Generazione di file
Sono possibili due tipi di output:
- File sorgente — scritti tramite
processingEnv.getFiler().createSourceFile(name). Il risultato è unJavaFileObjectil cuiopenWriter()si riempie con il codice sorgente. Il nuovo file viene compilato nel round successivo. - File di risorse — scritti tramite
getFiler().createResource(...)per tutto ciò che finisce sul classpath a runtime (ad es. registrazioni di servizi).
Il pattern consiste nel derivare il pacchetto e il nome della nuova classe dall'elemento annotato, quindi creare il sorgente come 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");
}Un processor reale tipicamente usa un generatore di codice come JavaPoet (che espone un builder tipizzato dell'AST) invece della concatenazione di stringhe. La meccanica è identica; JavaPoet rende solo il sorgente più leggibile.
Errori, avvisi, note
Un processor segnala i diagnostici tramite Messager:
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@Marker may only annotate top-level classes",
element);Kind.ERROR fa fallire la build in corrispondenza della posizione nel sorgente di quell'elemento. WARNING, MANDATORY_WARNING e NOTE sono i livelli inferiori. Passare sempre l'argomento Element quando possibile — fornisce all'utente una posizione sorgente cliccabile invece di un blob nel log di build.
Considerazioni sulla compilazione incrementale
Gli annotation processor sono una causa nota di rallentamenti della build. Due ragioni:
- Possono essere non incrementali: se al processor non viene indicato quali sorgenti ri-elaborare, il build tool ri-elabora tutto al cambiamento di qualsiasi sorgente.
- Possono bloccare il parallelismo: i round sono sequenziali.
Gradle ha introdotto le categorie di processor isolating e aggregating per consentire ai processor di partecipare alla compilazione incrementale. Un processor che produce un file generato per ogni sorgente annotato (Dagger fa questo per @Component) può dichiararsi "isolating" e Gradle lo esegue nuovamente solo per i sorgenti modificati. I processor aggregating — quelli che esaminano tutti gli elementi annotati per produrre un unico file di registro — vengono rieseguiti quando cambia qualsiasi sorgente annotato. Scegliere onestamente la categoria del processor; il compromesso è tra correttezza e velocità.
Un esempio pratico: un sostituto runtime per l'elaborazione a compile-time
L'elaborazione reale delle annotazioni richiede una build multi-modulo, il punto di estensione di javac e un file di servizio — nessuno dei quali rientra in un singolo programma. La dimostrazione migliore è un sostituto runtime che esegue lo stesso tipo di lavoro: scorre le classi annotate, le valida e scrive file sorgente in una directory temporanea come farebbe un processor a compile-time.
Cosa trarre dall'esecuzione:
- Il processor ha esaminato tre classi e ha agito su due — esattamente la forma di
RoundEnvironment.getElementsAnnotatedWith(Generate.class)in un vero processorjavac. La terza classe è stata ignorata silenziosamente perché l'annotazione non era presente. Questo è il modello: un processor consuma un insieme di elementi per round e lavora solo su quelli di suo interesse. - Ogni file generato portava il pacchetto della classe sorgente e un nome derivato. In
javax.lang.modelsi calcola il pacchetto daelementUtils.getPackageOf(typeElement).getQualifiedName()e il nome datypeElement.getSimpleName(); qui abbiamo usatoClass.getPackageName()eClass.getSimpleName()come analoghi. La forma si trasferisce. - L'elemento
suffixha fornito personalizzazione per uso:Accountha prodottoAccountGenerated,Invoiceha prodottoInvoiceHelper. Gli elementi dell'annotazione sono la manopola offerta all'utente; i valori predefiniti rendono il caso comune conciso e gli elementi denominati forniscono controllo preciso quando necessario. - La validazione simulata ha stampato una riga
ERROR:per le classi astratte. In un processor reale questo sarebbemessager.printMessage(Diagnostic.Kind.ERROR, "...", element)e la build fallirebbe nella posizione sorgente dell'utente. I diagnostici sono una funzionalità di prima classe, non un ripiego — usarli ogni volta che l'annotazione viene usata in modo errato, maithrow. - Il sorgente generato non contiene nulla di complicato — un
List.of(...)di nomi di campo e un helperorigin(). Questo è tipico. Il valore della generazione a compile-time è raramente l'ingegnosità dell'output; è che l'output esiste del tutto, prima che il programma venga eseguito, dove altrimenti il runtime avrebbe bisogno della reflection (pagandone il costo).
Quando ricorrere a un processor
Un processor ripaga il suo costo quando:
- Altrimenti si scriverebbe lo stesso boilerplate a mano per ogni classe annotata.
- Il lavoro può essere fatto dalle sole firme del sorgente (non è necessario il comportamento effettivo dell'istanza).
- L'alternativa a runtime userebbe la reflection su ogni chiamata, e quel costo si accumula.
Un processor è lo strumento sbagliato quando:
- Si vuole modificare una classe esistente. I processor standard possono solo aggiungere nuovi file sorgente; non riscrivono la classe annotata. (Lombok riscrive agganciadosi all'AST interno di
javac, che è non ufficiale e fragile.) - I metadati necessari esistono solo a runtime (scope di richiesta, identità utente, configurazione caricata da disco).
- Una semplice ricerca riflessiva all'avvio svolgerebbe lo stesso compito in 50 righe.
La decisione è la stessa di qualsiasi generazione di codice: più lavoro a compile-time, meno lavoro a runtime, e una build più difficile da debuggare. Valutare attentamente.
Fine della parte 16
Questo conclude la parte sulle Annotations del libro. Abbiamo trattato cosa è un'annotazione — puri metadati, distinti dal codice che viene eseguito — poi il piccolo insieme fornito dalla libreria standard, le cinque meta-annotazioni che configurano le proprie, la ricetta per dichiarare un'annotazione personalizzata, e infine l'API di elaborazione a compile-time che i framework usano per agire sulle annotazioni durante la build.
Il modello mentale da portare con sé: un'annotazione non fa mai nulla da sola. Qualcos'altro la legge e sceglie di agire. Quel "qualcos'altro" è o il compilatore (lint integrati), un annotation processor (generazione di codice a compile-time), o il proprio codice tramite reflection (framework a runtime). Quando un'annotazione non si comporta come previsto, la prima domanda è sempre: chi dovrebbe leggerla?
La parte successiva del libro è Reflection — il lato runtime dell'API che hai già iniziato a usare per leggere le annotazioni.