Pemrosesan Anotasi Java dan Membuat Builder

1. Perkenalan

Artikel ini adalah pengantar pemrosesan anotasi tingkat sumber Java dan memberikan contoh penggunaan teknik ini untuk membuat file sumber tambahan selama kompilasi.

2. Aplikasi Pengolahan Anotasi

Pemrosesan anotasi tingkat sumber pertama kali muncul di Java 5. Ini adalah teknik praktis untuk menghasilkan file sumber tambahan selama tahap kompilasi.

File sumber tidak harus berupa file Java - Anda dapat menghasilkan segala jenis deskripsi, metadata, dokumentasi, sumber daya, atau jenis file lainnya, berdasarkan anotasi di kode sumber Anda.

Pemrosesan anotasi secara aktif digunakan di banyak library Java yang ada di mana-mana, misalnya, untuk menghasilkan metaclass di QueryDSL dan JPA, untuk menambah kelas dengan kode boilerplate di library Lombok.

Satu hal penting untuk diperhatikan adalah batasan API pemrosesan anotasi - API ini hanya dapat digunakan untuk menghasilkan file baru, bukan untuk mengubah yang sudah ada .

Pengecualian penting adalah pustaka Lombok yang menggunakan pemrosesan anotasi sebagai mekanisme bootstrap untuk memasukkan dirinya ke dalam proses kompilasi dan memodifikasi AST melalui beberapa API kompilator internal. Teknik hacky ini tidak ada hubungannya dengan tujuan pemrosesan anotasi yang dimaksudkan dan oleh karena itu tidak dibahas dalam artikel ini.

3. API Pemrosesan Anotasi

Pemrosesan anotasi dilakukan dalam beberapa putaran. Setiap putaran dimulai dengan kompilator mencari anotasi dalam file sumber dan memilih pemroses anotasi yang cocok untuk anotasi ini. Setiap pemroses anotasi, pada gilirannya, dipanggil ke sumber yang sesuai.

Jika ada file yang dihasilkan selama proses ini, putaran lain dimulai dengan file yang dihasilkan sebagai inputnya. Proses ini berlanjut hingga tidak ada file baru yang dibuat selama tahap pemrosesan.

Setiap pemroses anotasi, pada gilirannya, dipanggil ke sumber yang sesuai. Jika ada file yang dihasilkan selama proses ini, putaran lain dimulai dengan file yang dihasilkan sebagai inputnya. Proses ini berlanjut hingga tidak ada file baru yang dibuat selama tahap pemrosesan.

API pemrosesan anotasi terletak di paket javax.annotation.processing . Antarmuka utama yang harus Anda implementasikan adalah antarmuka Prosesor , yang memiliki implementasi parsial dalam bentuk kelas AbstractProcessor . Kelas ini adalah kelas yang akan kita kembangkan untuk membuat pemroses anotasi kita sendiri.

4. Menyiapkan Proyek

Untuk mendemonstrasikan kemungkinan pemrosesan anotasi, kami akan mengembangkan prosesor sederhana untuk menghasilkan pembuat objek yang lancar untuk kelas beranotasi.

Kami akan membagi proyek kami menjadi dua modul Maven. Salah satunya, modul pemroses anotasi , akan berisi pemroses itu sendiri bersama dengan anotasi, dan yang lainnya, modul pengguna anotasi , akan berisi kelas beranotasi. Ini adalah kasus penggunaan umum dari pemrosesan anotasi.

Pengaturan untuk modul pemroses anotasi adalah sebagai berikut. Kita akan menggunakan pustaka layanan otomatis Google untuk menghasilkan file metadata prosesor yang akan dibahas nanti, dan maven-compiler-plugin yang disetel untuk kode sumber Java 8. Versi dependensi ini diekstrak ke bagian properti.

Versi terbaru pustaka layanan otomatis dan maven-compiler-plugin dapat ditemukan di repositori Pusat Maven:

 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    

The penjelasan-pengguna Maven modul dengan sumber dijelaskan tidak membutuhkan tuning khusus, kecuali menambahkan ketergantungan pada modul penjelasan-prosesor di bagian dependensi:

 com.baeldung annotation-processing 1.0.0-SNAPSHOT 

5. Mendefinisikan Annotation

Misalkan kita memiliki kelas POJO sederhana di modul pengguna anotasi dengan beberapa bidang:

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

Kami ingin membuat kelas pembantu builder untuk membuat instance kelas Person dengan lebih lancar:

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

Kelas PersonBuilder ini adalah pilihan yang jelas untuk sebuah generasi, karena strukturnya sepenuhnya ditentukan oleh metode penyetel 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

Untuk membuat file pendaftaran secara otomatis, Anda dapat menggunakan anotasi @AutoService dari pustaka layanan otomatis Google , seperti ini:

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

Anotasi ini sendiri diproses oleh pemroses anotasi dari pustaka layanan otomatis. Prosesor ini menghasilkan file META-INF / services / javax.annotation.processing.Processor yang berisi nama kelas BuilderProcessor .

9. Kesimpulan

Dalam artikel ini, kami telah mendemonstrasikan pemrosesan anotasi tingkat sumber menggunakan contoh pembuatan kelas Builder untuk POJO. Kami juga telah menyediakan beberapa cara alternatif untuk mendaftarkan pemroses anotasi dalam proyek Anda.

Kode sumber untuk artikel tersebut tersedia di GitHub.