Java Annotation Verarbeitung und Erstellung eines Builders

1. Einleitung

Dieser Artikel ist eine Einführung in die Annotationsverarbeitung auf Java-Quellenebene und enthält Beispiele für die Verwendung dieser Technik zum Generieren zusätzlicher Quelldateien während der Kompilierung.

2. Anwendungen der Anmerkungsverarbeitung

Die Annotationsverarbeitung auf Quellenebene wurde erstmals in Java 5 angezeigt. Dies ist eine praktische Technik zum Generieren zusätzlicher Quelldateien während der Kompilierungsphase.

Die Quelldateien müssen keine Java-Dateien sein. Sie können jede Art von Beschreibung, Metadaten, Dokumentation, Ressourcen oder anderen Dateitypen basierend auf Anmerkungen in Ihrem Quellcode generieren.

Die Verarbeitung von Anmerkungen wird in vielen allgegenwärtigen Java-Bibliotheken aktiv verwendet, um beispielsweise Metaklassen in QueryDSL und JPA zu generieren und Klassen mit Boilerplate-Code in der Lombok-Bibliothek zu erweitern.

Ein wichtiger Punkt ist die Einschränkung der Annotation Processing API - sie kann nur zum Generieren neuer Dateien verwendet werden, nicht zum Ändern vorhandener .

Die bemerkenswerte Ausnahme ist die Lombok-Bibliothek, die die Annotationsverarbeitung als Bootstrapping-Mechanismus verwendet, um sich in den Kompilierungsprozess einzubeziehen und den AST über einige interne Compiler-APIs zu ändern. Diese Hacky-Technik hat nichts mit dem beabsichtigten Zweck der Annotationsverarbeitung zu tun und wird daher in diesem Artikel nicht behandelt.

3. Annotation Processing API

Die Annotationsverarbeitung erfolgt in mehreren Runden. Jede Runde beginnt damit, dass der Compiler nach den Anmerkungen in den Quelldateien sucht und die für diese Anmerkungen geeigneten Anmerkungsprozessoren auswählt. Jeder Annotationsprozessor wird wiederum auf den entsprechenden Quellen aufgerufen.

Wenn während dieses Vorgangs Dateien generiert werden, wird eine weitere Runde mit den generierten Dateien als Eingabe gestartet. Dieser Vorgang wird fortgesetzt, bis während der Verarbeitungsphase keine neuen Dateien generiert werden.

Jeder Annotationsprozessor wird wiederum auf den entsprechenden Quellen aufgerufen. Wenn während dieses Vorgangs Dateien generiert werden, wird eine weitere Runde mit den generierten Dateien als Eingabe gestartet. Dieser Vorgang wird fortgesetzt, bis während der Verarbeitungsphase keine neuen Dateien generiert werden.

Die Annotation Processing API befindet sich im Paket javax.annotation.processing . Die Hauptschnittstelle, die Sie implementieren müssen, ist die Prozessorschnittstelle , die teilweise in Form einer AbstractProcessor- Klasse implementiert ist . Diese Klasse werden wir erweitern, um unseren eigenen Annotationsprozessor zu erstellen.

4. Einrichten des Projekts

Um die Möglichkeiten der Annotationsverarbeitung zu demonstrieren, werden wir einen einfachen Prozessor zum Generieren fließender Objekt-Builder für annotierte Klassen entwickeln.

Wir werden unser Projekt in zwei Maven-Module aufteilen. Eines davon, das Annotation-Prozessor- Modul, enthält den Prozessor selbst zusammen mit der Annotation, und ein anderes, das Annotation-User- Modul, enthält die annotierte Klasse. Dies ist ein typischer Anwendungsfall für die Verarbeitung von Anmerkungen.

Die Einstellungen für das Annotation-Prozessor- Modul sind wie folgt. Wir werden die Auto-Service-Bibliothek von Google verwenden, um Prozessormetadatendateien zu generieren, die später erläutert werden, und das Maven-Compiler-Plugin, das auf den Java 8-Quellcode abgestimmt ist. Die Versionen dieser Abhängigkeiten werden in den Abschnitt Eigenschaften extrahiert.

Die neuesten Versionen der Auto-Service-Bibliothek und des Maven-Compiler-Plugins finden Sie im Maven Central-Repository:

 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    

Das Annotation-User- Maven-Modul mit den annotierten Quellen benötigt keine spezielle Optimierung, außer das Hinzufügen einer Abhängigkeit vom Annotation-Prozessor-Modul im Abschnitt Abhängigkeiten:

 com.baeldung annotation-processing 1.0.0-SNAPSHOT 

5. Annotation definieren

Angenommen, wir haben eine einfache POJO-Klasse in unserem Annotation-User- Modul mit mehreren Feldern:

public class Person { private int age; private String name; // getters and setters … }

Wir möchten eine Builder-Hilfsklasse erstellen, um die Person- Klasse flüssiger zu instanziieren :

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

Diese PersonBuilder- Klasse ist eine offensichtliche Wahl für eine Generation, da ihre Struktur vollständig durch die Person- Setter-Methoden definiert wird .

Lassen Sie uns im Annotation-Processor- Modul eine @ BuilderProperty- Annotation für die Setter-Methoden erstellen . Damit können wir die Builder- Klasse für jede Klasse generieren , deren Setter-Methoden mit Anmerkungen versehen sind:

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

Die Annotation @Target mit dem Parameter ElementType.METHOD stellt sicher, dass diese Annotation nur für eine Methode verwendet werden kann.

Die SOURCE- Aufbewahrungsrichtlinie bedeutet, dass diese Anmerkung nur während der Quellverarbeitung verfügbar ist und zur Laufzeit nicht verfügbar ist.

Die Person- Klasse mit Eigenschaften, die mit der Annotation @BuilderProperty versehen sind, sieht folgendermaßen aus:

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. Implementieren eines Prozessors

6.1. Erstellen einer AbstractProcessor- Unterklasse

Wir beginnen mit der Erweiterung der AbstractProcessor- Klasse innerhalb des Annotation-Processor- Maven-Moduls.

Zunächst sollten wir Anmerkungen angeben, die dieser Prozessor verarbeiten kann, sowie die unterstützte Quellcodeversion. Dies kann entweder durch Implementieren der Methoden getSupportedAnnotationTypes und getSupportedSourceVersion der Prozessorschnittstelle oder durch Annotieren Ihrer Klasse mit den Annotationen @SupportedAnnotationTypes und @SupportedSourceVersion erfolgen .

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

Um die Registrierungsdatei automatisch zu generieren, können Sie die Annotation @AutoService aus der Auto-Service- Bibliothek von Google wie folgt verwenden :

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

Diese Annotation wird selbst vom Annotationsprozessor aus der Auto-Service-Bibliothek verarbeitet. Dieser Prozessor generiert die Datei META-INF / services / javax.annotation.processing.Processor , die den BuilderProcessor- Klassennamen enthält.

9. Fazit

In diesem Artikel haben wir die Annotationsverarbeitung auf Quellenebene anhand eines Beispiels zum Generieren einer Builder-Klasse für ein POJO demonstriert. Wir haben auch verschiedene alternative Möglichkeiten zur Registrierung von Anmerkungsprozessoren in Ihrem Projekt bereitgestellt.

Der Quellcode für den Artikel ist auf GitHub verfügbar.