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

1. Introduction

Cet article est une introduction au traitement des annotations au niveau de la source Java 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 au niveau de la source est apparu pour la première fois dans Java 5. C'est une technique pratique pour générer des fichiers source supplémentaires pendant la phase de compilation.

Les fichiers source 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 fichiers, 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, pour augmenter les classes avec du code standard dans la bibliothèque Lombok.

Une chose importante à noter est la limitation de l'API de traitement des annotations - elle ne peut être utilisée que pour générer de nouveaux fichiers, pas pour modifier les fichiers existants .

L'exception notable est la bibliothèque Lombok 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 piratage n'a rien à voir avec l'objectif prévu du traitement des annotations et n'est donc pas abordée dans cet article.

3. API de traitement des annotations

Le traitement des annotations se fait en plusieurs tours. Chaque tour commence par le compilateur recherchant les annotations dans les fichiers source et choisissant les processeurs d'annotations adaptés à ces annotations. Chaque processeur d'annotations, à son tour, est appelé sur les sources correspondantes.

Si des fichiers sont générés au cours de ce processus, un autre cycle est démarré 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'annotations, à son tour, est appelé sur les sources correspondantes. Si des fichiers sont générés au cours de ce processus, un autre cycle est démarré 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 package javax.annotation.processing . L'interface principale que vous devrez implémenter est l' interface Processor , qui a une implémentation partielle sous la forme de la classe AbstractProcessor . Cette classe est celle que nous allons étendre pour créer notre propre processeur d'annotations.

4. Configuration du projet

Pour démontrer les possibilités du traitement des annotations, nous développerons un processeur simple pour 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 module de processeur d'annotation , contiendra le processeur lui-même avec l'annotation, et un autre, le module d' annotation-utilisateur , contiendra la classe annotée. Il s'agit d'un cas d'utilisation typique du traitement des annotations.

Les paramètres du module processeur d'annotations sont les suivants. Nous allons utiliser la bibliothèque de services automatiques de Google pour générer un fichier de métadonnées du processeur qui sera discuté plus tard, et le plugin maven-compiler-plugin réglé pour le code source de Java 8. Les versions de ces dépendances sont extraites dans la section des propriétés.

Les dernières versions de la bibliothèque auto-service et de maven-compiler-plugin peuvent être trouvées 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 Maven utilisateur d'annotation 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.baeldung annotation-processing 1.0.0-SNAPSHOT 

5. Définition d'une annotation

Supposons que nous ayons une classe POJO simple dans notre module utilisateur d'annotation 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 classe Person plus couramment:

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

Cette classe PersonBuilder est un choix évident pour une génération, car sa structure est complètement définie par les méthodes du setter Person .

Let’s create a @BuilderProperty annotation in the annotation-processor module for the setter methods. It will allow us to generate the Builder class for each class that has its setter methods annotated:

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

The @Target annotation with the ElementType.METHOD parameter ensures that this annotation can be only put on a method.

The SOURCE retention policy means that this annotation is only available during source processing and is not available at runtime.

The Person class with properties annotated with the @BuilderProperty annotation will look as follows:

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. Implementing a Processor

6.1. Creating an AbstractProcessor Subclass

We’ll start with extending the AbstractProcessor class inside the annotation-processor Maven module.

First, we should specify annotations that this processor is capable of processing, and also the supported source code version. This can be done either by implementing the methods getSupportedAnnotationTypes and getSupportedSourceVersion of the Processor interface or by annotating your class with @SupportedAnnotationTypes and @SupportedSourceVersion annotations.

The @AutoService annotation is a part of the auto-service library and allows to generate the processor metadata which will be explained in the following sections.

@SupportedAnnotationTypes( "com.baeldung.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; } }

You can specify not only the concrete annotation class names but also wildcards, like “com.baeldung.annotation.*” to process annotations inside the com.baeldung.annotation package and all its sub packages, or even “*” to process all annotations.

The single method that we’ll have to implement is the process method that does the processing itself. It is called by the compiler for every source file containing the matching annotations.

Annotations are passed as the first Set annotations argument, and the information about the current processing round is passed as the RoundEnviroment roundEnv argument.

The return boolean value should be true if your annotation processor has processed all the passed annotations, and you don't want them to be passed to other annotation processors down the list.

6.2. Gathering Data

Our processor does not really do anything useful yet, so let’s fill it with code.

First, we’ll need to iterate through all annotation types that are found in the class — in our case, the annotations set will have a single element corresponding to the @BuilderProperty annotation, even if this annotation occurs multiple times in the source file.

Still, it’s better to implement the process method as an iteration cycle, for completeness sake:

@Override public boolean process(Set annotations, RoundEnvironment roundEnv) { for (TypeElement annotation : annotations) { Set annotatedElements = roundEnv.getElementsAnnotatedWith(annotation); // … } return true; }

In this code, we use the RoundEnvironment instance to receive all elements annotated with the @BuilderProperty annotation. In the case of the Person class, these elements correspond to the setName and setAge methods.

@BuilderProperty annotation's user could erroneously annotate methods that are not actually setters. The setter method name should start with set, and the method should receive a single argument. So let’s separate the wheat from the chaff.

In the following code, we use the Collectors.partitioningBy() collector to split annotated methods into two collections: correctly annotated setters and other erroneously annotated methods:

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);
    

Here we use the Element.asType() method to receive an instance of the TypeMirror class which gives us some ability to introspect types even though we are only at the source processing stage.

We should warn the user about incorrectly annotated methods, so let’s use the Messager instance accessible from the AbstractProcessor.processingEnv protected field. The following lines will output an error for each erroneously annotated element during the source processing stage:

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

Of course, if the correct setters collection is empty, there is no point of continuing the current type element set iteration:

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

If the setters collection has at least one element, we’re going to use it to get the fully qualified class name from the enclosing element, which in case of the setter method appears to be the source class itself:

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

The last bit of information we need to generate a builder class is a map between the names of the setters and the names of their argument types:

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

6.3. Generating the Output File

Now we have all the information we need to generate a builder class: the name of the source class, all its setter names, and their argument types.

To generate the output file, we’ll use the Filer instance provided again by the object in the AbstractProcessor.processingEnv protected property:

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

The complete code of the writeBuilderFile method is provided below. We only need to calculate the package name, fully qualified builder class name, and simple class names for the source class and the builder class. The rest of the code is pretty straightforward.

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. Running the Example

To see the code generation in action, you should either compile both modules from the common parent root or first compile the annotation-processor module and then the annotation-user module.

The generated PersonBuilder class can be found inside the annotation-user/target/generated-sources/annotations/com/baeldung/annotation/PersonBuilder.java file and should look like this:

package com.baeldung.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. Alternative Ways of Registering a Processor

To use your annotation processor during the compilation stage, you have several other options, depending on your use case and the tools you use.

8.1. Using the Annotation Processor Tool

The apt tool was a special command line utility for processing source files. It was a part of Java 5, but since Java 7 it was deprecated in favour of other options and removed completely in Java 8. It will not be discussed in this article.

8.2. Using the Compiler Key

The -processor compiler key is a standard JDK facility to augment the source processing stage of the compiler with your own annotation processor.

Note that the processor itself and the annotation have to be already compiled as classes in a separate compilation and present on the classpath, so the first thing you should do is:

javac com/baeldung/annotation/processor/BuilderProcessor javac com/baeldung/annotation/processor/BuilderProperty

Then you do the actual compilation of your sources with the -processor key specifying the annotation processor class you’ve just compiled:

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

To specify several annotation processors in one go, you can separate their class names with commas, like this:

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

8.3. Using Maven

The maven-compiler-plugin allows specifying annotation processors as part of its configuration.

Here’s an example of adding annotation processor for the compiler plugin. You could also specify the directory to put generated sources into, using the generatedSourcesDirectory configuration parameter.

Note that the BuilderProcessor class should already be compiled, for instance, imported from another jar in the build dependencies:

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

8.4. Adding a Processor Jar to the Classpath

Instead of specifying the annotation processor in the compiler options, you may simply add a specially structured jar with the processor class to the classpath of the compiler.

To pick it up automatically, the compiler has to know the name of the processor class. So you have to specify it in the META-INF/services/javax.annotation.processing.Processor file as a fully qualified class name of the processor:

com.baeldung.annotation.processor.BuilderProcessor

You can also specify several processors from this jar to pick up automatically by separating them with a new line:

package1.Processor1 package2.Processor2 package3.Processor3

If you use Maven to build this jar and try to put this file directly into the src/main/resources/META-INF/services directory, you’ll encounter the following error:

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

This is because the compiler tries to use this file during the source-processing stage of the module itself when the BuilderProcessor file is not yet compiled. The file has to be either put inside another resource directory and copied to the META-INF/services directory during the resource copying stage of the Maven build, or (even better) generated during the build.

The Google auto-service library, discussed in the following section, allows generating this file using a simple annotation.

8.5. Using the Google auto-service Library

Pour générer automatiquement le fichier d'enregistrement, vous pouvez utiliser l' annotation @AutoService de la bibliothèque de services automatiques de Google , comme ceci:

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

Cette annotation est elle-même traitée par le processeur d'annotations de la bibliothèque d'auto-service. Ce processeur génère le META-INF / services / javax.annotation.processing.Processor fichier contenant le BuilderProcessor nom de classe.

9. Conclusion

Dans cet article, nous avons présenté le traitement des annotations au niveau de la source à l'aide d'un exemple de génération d'une classe Builder pour un POJO. Nous avons également fourni plusieurs méthodes alternatives d'enregistrement des processeurs d'annotations dans votre projet.

Le code source de l'article est disponible sur GitHub.