Traitement des annotations Java et création d’un générateur

Traitement des annotations Java et création d'un générateur

1. introduction

Cet article estan intro to Java source-level annotation processing et fournit des exemples d'utilisation de cette technique pour générer des fichiers source supplémentaires lors de la compilation.

2. Applications du traitement des annotations

Le traitement des annotations de niveau source est apparu pour la première fois dans Java 5. C'est une technique pratique pour générer des fichiers sources supplémentaires pendant la phase de compilation.

Les fichiers sources ne doivent pas nécessairement être des fichiers Java - vous pouvez générer tout type de description, métadonnées, documentation, ressources ou tout autre type de fichier, en fonction des annotations de votre code source.

Le traitement des annotations est activement utilisé dans de nombreuses bibliothèques Java omniprésentes, par exemple pour générer des métaclasses dans QueryDSL et JPA, afin d’augmenter les classes avec du code passe-partout dans la bibliothèque Lombok.

Une chose importante à noter estthe limitation of the annotation processing API — it can only be used to generate new files, not to change existing ones.

L'exception notable est la bibliothèqueLombok qui utilise le traitement des annotations comme mécanisme d'amorçage pour s'inclure dans le processus de compilation et modifier l'AST via certaines API internes du compilateur. Cette technique de hacky n’a rien à voir avec le but recherché du traitement des annotations et n’est donc pas traitée dans cet article.

3. API de traitement des annotations

Le traitement des annotations est effectué en plusieurs tours. Chaque tour commence par la compilation par le compilateur des annotations dans les fichiers source et par la sélection des processeurs d’annotation adaptés à ces annotations. Chaque processeur d'annotation, à son tour, est appelé sur les sources correspondantes.

Si des fichiers sont générés au cours de ce processus, une autre série est lancée avec les fichiers générés en entrée. Ce processus se poursuit jusqu'à ce qu'aucun nouveau fichier ne soit généré pendant la phase de traitement.

Chaque processeur d'annotation, à son tour, est appelé sur les sources correspondantes. Si des fichiers sont générés au cours de ce processus, une autre série est lancée avec les fichiers générés en entrée. Ce processus se poursuit jusqu'à ce qu'aucun nouveau fichier ne soit généré pendant la phase de traitement.

L'API de traitement des annotations se trouve dans le packagejavax.annotation.processing. L'interface principale que vous devrez implémenter est l'interfaceProcessor, qui a une implémentation partielle sous la forme de la classeAbstractProcessor. Cette classe est celle que nous allons étendre pour créer notre propre processeur d’annotation.

4. Mise en place du projet

Pour illustrer les possibilités du traitement des annotations, nous allons développer un processeur simple permettant de générer des générateurs d’objets fluides pour les classes annotées.

Nous allons diviser notre projet en deux modules Maven. L'un d'eux, le moduleannotation-processor, contiendra le processeur lui-même avec l'annotation, et un autre, le moduleannotation-user, contiendra la classe annotée. C'est un cas d'utilisation typique du traitement des annotations.

Les paramètres du moduleannotation-processor sont les suivants. Nous allons utiliser la bibliothèqueauto-service de Google pour générer le fichier de métadonnées du processeur qui sera abordé plus tard, et lesmaven-compiler-pluginréglés pour le code source Java 8. Les versions de ces dépendances sont extraites dans la section des propriétés.

Les dernières versions de la bibliothèqueauto-service et demaven-compiler-plugin se trouvent dans le référentiel Maven Central:


    1.0-rc2
    
      3.5.1
    




    
        com.google.auto.service
        auto-service
        ${auto-service.version}
        provided
    




    

        
            org.apache.maven.plugins
            maven-compiler-plugin
            ${maven-compiler-plugin.version}
            
                1.8
                1.8
            
        

    

Le module Mavenannotation-user avec les sources annotées ne nécessite aucun réglage spécial, sauf l'ajout d'une dépendance sur le module de processeur d'annotation dans la section des dépendances:


    com.example
    annotation-processing
    1.0.0-SNAPSHOT

5. Définition d'une annotation

Supposons que nous ayons une classe POJO simple dans notre moduleannotation-user avec plusieurs champs:

public class Person {

    private int age;

    private String name;

    // getters and setters …

}

Nous voulons créer une classe d'assistance de générateur pour instancier la classePerson plus couramment:

Person person = new PersonBuilder()
  .setAge(25)
  .setName("John")
  .build();

Cette classePersonBuilder est un choix évident pour une génération, car sa structure est complètement définie par les méthodes de définition dePerson.

Créons une annotation@BuilderProperty dans le moduleannotation-processor pour les méthodes setter. Cela nous permettra de générer la classeBuilder pour chaque classe dont les méthodes setter sont annotées:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface BuilderProperty {
}

L'annotation@Target avec le paramètreElementType.METHOD garantit que cette annotation ne peut être placée que sur une méthode.

La politique de rétention deSOURCE signifie que cette annotation n'est disponible que pendant le traitement de la source et n'est pas disponible au moment de l'exécution.

La classePerson avec les propriétés annotées avec l'annotation@BuilderProperty ressemblera à ceci:

public class Person {

    private int age;

    private String name;

    @BuilderProperty
    public void setAge(int age) {
        this.age = age;
    }

    @BuilderProperty
    public void setName(String name) {
        this.name = name;
    }

    // getters …

}

6. Implémentation d'unProcessor

6.1. Création d'une sous-classeAbstractProcessor

Nous allons commencer par étendre la classeAbstractProcessor à l'intérieur du module Mavenannotation-processor.

Tout d'abord, nous devons spécifier les annotations que ce processeur est capable de traiter, ainsi que la version du code source prise en charge. Cela peut être fait soit en implémentant les méthodesgetSupportedAnnotationTypes etgetSupportedSourceVersion de l'interfaceProcessor soit en annotant votre classe avec les annotations@SupportedAnnotationTypes et@SupportedSourceVersion.

L'annotation@AutoService fait partie de la bibliothèqueauto-service et permet de générer les métadonnées du processeur qui seront expliquées dans les sections suivantes.

@SupportedAnnotationTypes(
  "com.example.annotation.processor.BuilderProperty")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@AutoService(Processor.class)
public class BuilderProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set annotations,
      RoundEnvironment roundEnv) {
        return false;
    }
}

Vous pouvez spécifier non seulement les noms de classes d'annotations concrètes, mais également des caractères génériques, comme“com.example.annotation. pour traiter les annotations à l'intérieur du packagecom.example.annotation et tous ses sous-packages, ou même“” pour traiter toutes les annotations.

La seule méthode que nous devrons implémenter est la méthodeprocess qui effectue le traitement lui-même. Il est appelé par le compilateur pour chaque fichier source contenant les annotations correspondantes.

Les annotations sont transmises comme premier argumentSet<? extends TypeElement> annotations, et les informations sur le cycle de traitement en cours sont transmises comme argumentRoundEnviroment roundEnv.

La valeur de retourboolean doit êtretrue si votre processeur d'annotations a traité toutes les annotations transmises et que vous ne souhaitez pas qu'elles soient transmises à d'autres processeurs d'annotations dans la liste.

6.2. Rassembler des données

Notre processeur ne fait encore rien d’utile, alors remplissons-le de code.

Tout d'abord, nous devrons parcourir tous les types d'annotations qui se trouvent dans la classe - dans notre cas, l'ensembleannotations aura un seul élément correspondant à l'annotation@BuilderProperty, même si cette annotation se produit plusieurs fois dans le fichier source.

Néanmoins, il est préférable d’implémenter la méthodeprocess en tant que cycle d’itération, par souci d’exhaustivité:

@Override
public boolean process(Set annotations,
  RoundEnvironment roundEnv) {

    for (TypeElement annotation : annotations) {
        Set annotatedElements
          = roundEnv.getElementsAnnotatedWith(annotation);

        // …
    }

    return true;
}

Dans ce code, nous utilisons l'instanceRoundEnvironment pour recevoir tous les éléments annotés avec l'annotation@BuilderProperty. Dans le cas de la classePerson, ces éléments correspondent aux méthodessetName etsetAge.

L'utilisateur de l'annotation@BuilderProperty pourrait annoter par erreur des méthodes qui ne sont pas réellement des setters. Le nom de la méthode de définition doit commencer parset et la méthode doit recevoir un seul argument. Alors séparons le bon grain de la balle.

Dans le code suivant, nous utilisons le collecteurCollectors.partitioningBy() pour diviser les méthodes annotées en deux collections: setters correctement annotés et autres méthodes annotées par erreur:

Map> annotatedMethods = annotatedElements.stream().collect(
  Collectors.partitioningBy(element ->
    ((ExecutableType) element.asType()).getParameterTypes().size() == 1
    && element.getSimpleName().toString().startsWith("set")));

List setters = annotatedMethods.get(true);
List otherMethods = annotatedMethods.get(false);

Ici, nous utilisons la méthodeElement.asType() pour recevoir une instance de la classeTypeMirror qui nous donne une certaine capacité à introspecter les types même si nous ne sommes qu'au stade du traitement source.

Nous devons avertir l'utilisateur des méthodes incorrectement annotées, alors utilisons l'instanceMessager accessible depuis le champ protégéAbstractProcessor.processingEnv. Les lignes suivantes généreront une erreur pour chaque élément annoté par erreur au cours de la phase de traitement source:

otherMethods.forEach(element ->
  processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
    "@BuilderProperty must be applied to a setXxx method "
      + "with a single argument", element));

Bien sûr, si la collection de correcteurs corrects est vide, il est inutile de poursuivre l'itération du jeu d'éléments de type en cours:

if (setters.isEmpty()) {
    continue;
}

Si la collection de setters a au moins un élément, nous allons l’utiliser pour obtenir le nom de classe complet de l’élément englobant, ce qui dans le cas de la méthode setter semble être la classe source elle-même:

String className = ((TypeElement) setters.get(0)
  .getEnclosingElement()).getQualifiedName().toString();

La dernière information dont nous avons besoin pour générer une classe de générateur est un mappage entre les noms des setters et les noms de leurs types d'argument:

Map setterMap = setters.stream().collect(Collectors.toMap(
    setter -> setter.getSimpleName().toString(),
    setter -> ((ExecutableType) setter.asType())
      .getParameterTypes().get(0).toString()
));

6.3. Générer le fichier de sortie

Nous avons maintenant toutes les informations nécessaires pour générer une classe de générateur: le nom de la classe source, tous ses noms de setter et leurs types d'arguments.

Pour générer le fichier de sortie, nous utiliserons à nouveau l'instanceFiler fournie par l'objet dans la propriété protégéeAbstractProcessor.processingEnv:

JavaFileObject builderFile = processingEnv.getFiler()
  .createSourceFile(builderClassName);
try (PrintWriter out = new PrintWriter(builderFile.openWriter())) {
    // writing generated file to out …
}

Le code complet de la méthodewriteBuilderFile est fourni ci-dessous. Il suffit de calculer le nom du package, le nom de la classe de générateur pleinement qualifié et des noms de classe simples pour la classe source et la classe de générateur. Le reste du code est assez simple.

private void writeBuilderFile(
  String className, Map setterMap)
  throws IOException {

    String packageName = null;
    int lastDot = className.lastIndexOf('.');
    if (lastDot > 0) {
        packageName = className.substring(0, lastDot);
    }

    String simpleClassName = className.substring(lastDot + 1);
    String builderClassName = className + "Builder";
    String builderSimpleClassName = builderClassName
      .substring(lastDot + 1);

    JavaFileObject builderFile = processingEnv.getFiler()
      .createSourceFile(builderClassName);

    try (PrintWriter out = new PrintWriter(builderFile.openWriter())) {

        if (packageName != null) {
            out.print("package ");
            out.print(packageName);
            out.println(";");
            out.println();
        }

        out.print("public class ");
        out.print(builderSimpleClassName);
        out.println(" {");
        out.println();

        out.print("    private ");
        out.print(simpleClassName);
        out.print(" object = new ");
        out.print(simpleClassName);
        out.println("();");
        out.println();

        out.print("    public ");
        out.print(simpleClassName);
        out.println(" build() {");
        out.println("        return object;");
        out.println("    }");
        out.println();

        setterMap.entrySet().forEach(setter -> {
            String methodName = setter.getKey();
            String argumentType = setter.getValue();

            out.print("    public ");
            out.print(builderSimpleClassName);
            out.print(" ");
            out.print(methodName);

            out.print("(");

            out.print(argumentType);
            out.println(" value) {");
            out.print("        object.");
            out.print(methodName);
            out.println("(value);");
            out.println("        return this;");
            out.println("    }");
            out.println();
        });

        out.println("}");
    }
}

7. Exécuter l'exemple

Pour voir la génération de code en action, vous devez soit compiler les deux modules à partir de la racine parent commune, soit d'abord compiler le moduleannotation-processor puis le moduleannotation-user.

La classePersonBuilder générée peut être trouvée dans le fichierannotation-user/target/generated-sources/annotations/com/example/annotation/PersonBuilder.java et devrait ressembler à ceci:

package com.example.annotation;

public class PersonBuilder {

    private Person object = new Person();

    public Person build() {
        return object;
    }

    public PersonBuilder setName(java.lang.String value) {
        object.setName(value);
        return this;
    }

    public PersonBuilder setAge(int value) {
        object.setAge(value);
        return this;
    }
}

8. Autres méthodes d'enregistrement d'un processeur

Pour utiliser votre processeur d'annotations lors de la compilation, vous disposez de plusieurs autres options, en fonction de votre cas d'utilisation et des outils que vous utilisez.

8.1. Utilisation de l'outil Processeur d'annotations

L'outilapt était un utilitaire de ligne de commande spécial pour le traitement des fichiers source. Il faisait partie de Java 5, mais depuis Java 7, il est devenu obsolète au profit d’autres options et complètement supprimé dans Java 8. Cela ne sera pas discuté dans cet article.

8.2. Utilisation de la clé du compilateur

La clé de compilateur-processor est une fonction JDK standard pour augmenter l'étape de traitement des sources du compilateur avec votre propre processeur d'annotations.

Notez que le processeur lui-même et l'annotation doivent déjà être compilés en tant que classes dans une compilation séparée et présents sur le chemin de classe. La première chose à faire est donc:

javac com/example/annotation/processor/BuilderProcessor
javac com/example/annotation/processor/BuilderProperty

Ensuite, vous faites la compilation proprement dite de vos sources avec la clé-processor spécifiant la classe de processeur d'annotations que vous venez de compiler:

javac -processor com.example.annotation.processor.MyProcessor Person.java

Pour spécifier plusieurs processeurs d'annotation en une fois, vous pouvez séparer leurs noms de classes par des virgules, comme suit:

javac -processor package1.Processor1,package2.Processor2 SourceFile.java

8.3. Utiliser Maven

Lemaven-compiler-plugin permet de spécifier des processeurs d'annotations dans le cadre de sa configuration.

Voici un exemple d’ajout de processeur d’annotation pour le plug-in du compilateur. Vous pouvez également spécifier le répertoire dans lequel placer les sources générées, en utilisant le paramètre de configurationgeneratedSourcesDirectory.

Notez que la classeBuilderProcessor doit déjà être compilée, par exemple importée d'un autre fichier jar dans les dépendances de construction:


    

        
            org.apache.maven.plugins
            maven-compiler-plugin
            3.5.1
            
                1.8
                1.8
                UTF-8
                ${project.build.directory}
                  /generated-sources/
                
                    
                        com.example.annotation.processor.BuilderProcessor
                    
                
            
        

    

8.4. Ajout d'un processeur Jar au Classpath

Au lieu de spécifier le processeur d'annotation dans les options du compilateur, vous pouvez simplement ajouter un fichier jar spécialement structuré avec la classe de processeur au chemin d'accès aux classes du compilateur.

Pour le récupérer automatiquement, le compilateur doit connaître le nom de la classe de processeur. Vous devez donc le spécifier dans le fichierMETA-INF/services/javax.annotation.processing.Processor en tant que nom de classe complet du processeur:

com.example.annotation.processor.BuilderProcessor

Vous pouvez également spécifier plusieurs processeurs de ce fichier à récupérer automatiquement en les séparant par une nouvelle ligne:

package1.Processor1
package2.Processor2
package3.Processor3

Si vous utilisez Maven pour créer ce fichier jar et essayez de placer ce fichier directement dans le répertoiresrc/main/resources/META-INF/services, vous rencontrerez l'erreur suivante:

[ERROR] Bad service configuration file, or exception thrown while
constructing Processor object: javax.annotation.processing.Processor:
Provider com.example.annotation.processor.BuilderProcessor not found

Cela est dû au fait que le compilateur essaie d'utiliser ce fichier pendant l'étapesource-processing du module lui-même lorsque le fichierBuilderProcessor n'est pas encore compilé. Le fichier doit être soit placé dans un autre répertoire de ressources et copié dans le répertoireMETA-INF/services pendant la phase de copie des ressources de la construction Maven, soit (mieux encore) généré pendant la construction.

La bibliothèque Googleauto-service, abordée dans la section suivante, permet de générer ce fichier à l'aide d'une simple annotation.

8.5. Utilisation de la bibliothèque Googleauto-service

Pour générer automatiquement le fichier d’inscription, vous pouvez utiliser l’annotation@AutoService de la bibliothèqueauto-service de Google, comme suit:

@AutoService(Processor.class)
public BuilderProcessor extends AbstractProcessor {
    // …
}

Cette annotation est elle-même traitée par le processeur d'annotation à partir de la bibliothèque de services automatiques. Ce processeur génère le fichierMETA-INF/services/javax.annotation.processing.Processor contenant le nom de classeBuilderProcessor.

9. Conclusion

Dans cet article, nous avons présenté le traitement des annotations au niveau source à l'aide d'un exemple de génération d'une classe Builder pour un POJO. Nous avons également proposé plusieurs manières d’enregistrer les processeurs d’annotation dans votre projet.

Le code source de l'article est disponibleon GitHub.