Traitement des annotations Java
Traitez les annotations Java à la compilation avec l'API javax.annotation.processing pour générer du code ou valider les sources.
Le traitement des annotations est un point d'extension dans javac. Vous écrivez une classe — un processeur d'annotations — que le compilateur appelle pendant la compilation, à laquelle il transmet les éléments déjà observés et attend une réponse. Le processeur peut effectuer deux opérations utiles : valider le code annoté (émettre des erreurs ou des avertissements via le canal de diagnostic de javac) ou écrire de nouveaux fichiers source qui participent à la même compilation.
Les frameworks que vous avez probablement déjà utilisés sont alimentés par ce mécanisme :
- Lombok réécrit les classes annotées pour ajouter des getters, des builders et les méthodes
equals/hashCode. - Dagger / Hilt génèrent le câblage d'injection de dépendances en réponse à
@Injectet@Module. - Le métamodèle statique d'Hibernate génère des classes
Entity_pour des requêtes Criteria typées. - Auto-Service / Auto-Value génèrent les entrées de service
META-INFet les classes de valeur sans code répétitif. - Micronaut / Quarkus génèrent le câblage du framework à la compilation plutôt qu'au démarrage.
L'API du processeur se trouve dans javax.annotation.processing et le modèle de langage dans javax.lang.model. Ensemble, ils permettent à javac d'accueillir des outils tiers à la compilation.
La structure d'un processeur
Un processeur implémente javax.annotation.processing.Processor. En pratique, vous étendez AbstractProcessor et surchargez 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
}
}Les deux annotations sur la classe déclarent quels types d'annotations ce processeur souhaite gérer et quel niveau de langage il cible. Les deux peuvent aussi être retournées dynamiquement depuis getSupportedAnnotationTypes() / getSupportedSourceVersion() si vous devez les calculer.
process est appelé par tour. Chaque tour est un passage à travers les sources ; si votre processeur produit de nouveaux fichiers, ces nouveaux fichiers sont eux-mêmes traités lors d'un tour suivant. La boucle se termine quand aucun tour ne produit de nouveaux fichiers.
Le modèle de langage : pas de la réflexion
La première surprise : à l'intérieur d'un processeur, vous n'avez pas Class<?>. Les classes en cours de traitement n'ont pas encore été compilées. À la place, vous travaillez avec les types javax.lang.model.element :
Element— tout ce qui existe dans la source : une classe, méthode, champ, paramètre, package.TypeElement— une classe, interface ou enum (unElementsur lequel vous pouvez appelergetQualifiedName()).ExecutableElement— une méthode ou un constructeur.VariableElement— un champ, paramètre ou variable locale.TypeMirror— un type (comme "le typeList<String>"), distinct de l'élément qui l'a déclaré.
Ces types reflètent les types de réflexion à l'exécution, mais représentent la source, pas les classes chargées. Vous pouvez les parcourir, interroger leurs annotations, interroger leur portée englobante. Vous ne pouvez pas appeler des méthodes sur eux, évaluer arbitrairement des expressions constantes, ni les instancier — il n'existe pas encore d'instance.
Pour lire les valeurs d'éléments d'une annotation, vous utilisez Element.getAnnotation(MyAnn.class) (retourne un proxy, similaire à la réflexion) ou Element.getAnnotationMirrors() (retourne la forme structurelle, ce dont vous avez besoin quand la valeur de l'élément contient une référence Class à un type également en cours de compilation lors de ce même tour).
Enregistrer le processeur
Le compilateur doit trouver votre processeur. Il y a deux façons de procéder :
- Fichier service-loader. Placez un fichier nommé
META-INF/services/javax.annotation.processing.Processordans le classpath du processeur, dont le contenu est le nom complet de la classe du processeur, un par ligne. C'est ce que des outils commeauto-servicede Google génèrent automatiquement. - Flag
-processor. Passez-processor com.example.MarkerProcessoràjavac(ou configurez-le dans votre outil de build — la configurationannotationProcessorde Gradle,<annotationProcessorPaths>de Maven).
Avec Maven et Gradle, la convention est de garder le processeur dans son propre module et d'en dépendre depuis votre module principal avec annotationProcessor (Gradle) / <scope>provided</scope> (Maven). Le processeur ne s'exécute que pendant la compilation et n'est pas livré à l'exécution.
Générer des fichiers
Deux types de sorties sont possibles :
- Fichiers source — écrits via
processingEnv.getFiler().createSourceFile(name). Le résultat est unJavaFileObjectdont vous remplissezopenWriter()avec du code source. Le nouveau fichier est compilé lors du tour suivant. - Fichiers de ressources — écrits via
getFiler().createResource(...)pour tout ce qui se retrouve dans le classpath à l'exécution (par exemple, les enregistrements de services).
Le schéma consiste à dériver le package et le nom de la nouvelle classe à partir de l'élément annoté, puis à générer la source sous forme de 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 vrai processeur utilise généralement un générateur de code comme JavaPoet (qui expose un constructeur d'AST typé) plutôt que la concaténation de chaînes. La mécanique est identique ; JavaPoet rend simplement la source plus lisible.
Erreurs, avertissements, notes
Un processeur signale les diagnostics via Messager :
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@Marker may only annotate top-level classes",
element);Kind.ERROR fait échouer le build à la position source de cet élément. WARNING, MANDATORY_WARNING et NOTE sont les niveaux inférieurs. Passez toujours l'argument Element quand vous le pouvez — il donne à l'utilisateur un emplacement source cliquable plutôt qu'un bloc de journal de build.
Considérations sur la compilation incrémentielle
Les processeurs d'annotations sont une cause connue de ralentissements de build. Deux raisons :
- Ils peuvent être non-incrémentaux : si le processeur n'est pas informé des sources à retraiter, l'outil de build retraite tout quand une source quelconque change.
- Ils peuvent bloquer le parallélisme : les tours sont séquentiels.
Gradle a introduit les catégories de processeurs isolating et aggregating pour permettre aux processeurs de participer à la compilation incrémentielle. Un processeur qui produit un fichier généré par source annotée (Dagger le fait pour @Component) peut se déclarer "isolating" et Gradle ne le réexécute que pour les sources modifiées. Les processeurs aggregating — ceux qui parcourent tous les éléments annotés pour produire un seul fichier de registre — se réexécutent quand une source annotée quelconque change. Choisissez honnêtement la catégorie du processeur ; le compromis est entre la correction et la vitesse.
Un exemple concret : un substitut à l'exécution pour le traitement à la compilation
Le vrai traitement des annotations nécessite un build multi-modules, le point d'extension javac et un fichier de service — aucun de ceux-ci ne tient dans un seul programme. La meilleure démonstration alternative est un substitut à l'exécution qui effectue le même type de travail : parcourir les classes annotées, les valider et écrire des fichiers source dans un répertoire temporaire comme le ferait un processeur à la compilation.
Ce qu'il faut retenir de l'exécution :
- Le processeur a parcouru trois classes et en a traité deux — exactement la forme de
RoundEnvironment.getElementsAnnotatedWith(Generate.class)dans un vrai processeurjavac. La troisième classe a été silencieusement ignorée parce que son annotation n'était pas présente. C'est le modèle : un processeur consomme un ensemble d'éléments par tour et ne travaille que sur ceux qui l'intéressent. - Chaque fichier généré portait le package de la classe source et un nom dérivé. Dans
javax.lang.model, vous calculez le package depuiselementUtils.getPackageOf(typeElement).getQualifiedName()et le nom depuistypeElement.getSimpleName(); ici nous avons utiliséClass.getPackageName()etClass.getSimpleName()comme analogues. La forme est transférable. - L'élément
suffixa permis une personnalisation par usage :Accounta produitAccountGenerated,Invoicea produitInvoiceHelper. Les éléments d'annotation sont le levier que vous offrez à l'utilisateur ; les valeurs par défaut rendent le cas courant concis et les éléments nommés offrent un contrôle précis quand nécessaire. - La validation simulée a affiché une ligne
ERROR:pour les classes abstraites. Dans un vrai processeur, ce seraitmessager.printMessage(Diagnostic.Kind.ERROR, "...", element)et le build échouerait à l'emplacement source de l'utilisateur. Les diagnostics sont une fonctionnalité de premier plan, pas un recours — utilisez-les chaque fois que l'annotation est mal utilisée, n'utilisez jamaisthrow. - La source générée ne contient rien de complexe — un
List.of(...)de noms de champs et un helperorigin(). C'est typique. La valeur de la génération à la compilation est rarement l'ingéniosité du résultat ; c'est que le résultat existe, avant l'exécution du programme, là où l'exécution nécessiterait autrement de la réflexion (et en paierait le coût).
Quand recourir à un processeur
Un processeur vaut le coût quand :
- Vous écririez autrement le même code répétitif à la main pour chaque classe annotée.
- Le travail peut être effectué à partir des seules signatures de source (aucun comportement d'instance réel nécessaire).
- L'alternative à l'exécution utiliserait la réflexion à chaque appel, et ce coût s'accumule.
Un processeur est le mauvais outil quand :
- Vous souhaitez modifier une classe existante. Les processeurs standard ne peuvent qu'ajouter de nouveaux fichiers source ; ils ne réécrivent pas la classe annotée. (Lombok réécrit en se branchant dans l'AST interne de
javac, ce qui est non officiel et fragile.) - Les métadonnées dont vous avez besoin n'existent qu'à l'exécution (portée de requête, identité de l'utilisateur, configuration chargée depuis le disque).
- Une simple recherche réflexive au démarrage ferait le même travail en 50 lignes.
La décision est la même que pour toute génération de code : plus de travail à la compilation, moins de travail à l'exécution, et un build plus difficile à déboguer. Pesez soigneusement le compromis.
Fin de la partie 16
Ceci conclut la partie Annotations du livre. Nous avons couvert ce qu'est une annotation — des métadonnées pures, distinctes du code qui s'exécute — puis le petit ensemble fourni par la bibliothèque standard, les cinq méta-annotations qui configurent les vôtres, la recette pour déclarer une annotation personnalisée, et enfin l'API de traitement à la compilation que les frameworks utilisent pour agir sur les annotations pendant le build.
Le modèle mental à retenir : une annotation ne fait jamais rien. Quelque chose d'autre la lit et choisit d'agir. Ce "quelque chose d'autre" est soit le compilateur (vérifications intégrées), soit un processeur d'annotations (génération de code à la compilation), soit votre propre code via la réflexion (frameworks à l'exécution). Quand une annotation ne se comporte pas comme prévu, la première question est toujours : qui est censé la lire ?
La prochaine partie du livre est Reflection — le côté exécution de l'API que vous avez déjà commencé à utiliser pour lire les annotations.