W3docs

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 @Inject e @Module.
  • Il metamodello statico di Hibernate genera classi Entity_ per query Criteria type-safe.
  • Auto-Service / Auto-Value generano entry boilerplate in META-INF e 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 (un Element a cui si può chiedere getQualifiedName()).
  • ExecutableElement — un metodo o un costruttore.
  • VariableElement — un campo, parametro o variabile locale.
  • TypeMirror — un tipo (come "il tipo List<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:

  1. File service-loader. Inserire un file denominato META-INF/services/javax.annotation.processing.Processor nel classpath del processor, il cui contenuto è il nome completo della classe del processor, uno per riga. È quello che strumenti come auto-service di Google generano automaticamente.
  2. Flag -processor. Passare -processor com.example.MarkerProcessor a javac (o configurarlo nel build tool — la configurazione annotationProcessor di 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 è un JavaFileObject il cui openWriter() 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.

java— editable, runs on the server

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 processor javac. 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.model si calcola il pacchetto da elementUtils.getPackageOf(typeElement).getQualifiedName() e il nome da typeElement.getSimpleName(); qui abbiamo usato Class.getPackageName() e Class.getSimpleName() come analoghi. La forma si trasferisce.
  • L'elemento suffix ha fornito personalizzazione per uso: Account ha prodotto AccountGenerated, Invoice ha prodotto InvoiceHelper. 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 sarebbe messager.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, mai throw.
  • Il sorgente generato non contiene nulla di complicato — un List.of(...) di nomi di campo e un helper origin(). 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.

Esercitazione

Pratica
Un annotation processor genera un file sorgente 'Module' che aggrega ogni classe annotata con `@Service`. Le build sono lente perché ogni modifica a qualsiasi sorgente innesca una rielaborazione completa. Qual è la classificazione Gradle più appropriata?
Un annotation processor genera un file sorgente 'Module' che aggrega ogni classe annotata con `@Service`. Le build sono lente perché ogni modifica a qualsiasi sorgente innesca una rielaborazione completa. Qual è la classificazione Gradle più appropriata?
Was this page helpful?