Introducción
Este artículo es una introducción al procesamiento de anotaciones a nivel de código fuente en Java y proporciona ejemplos de cómo utilizar esta técnica para generar archivos fuente adicionales durante la compilación.
Aplicaciones del Procesamiento de Anotaciones
El procesamiento de anotaciones a nivel de código fuente apareció por primera vez en Java 5. Es una técnica útil para generar archivos fuente adicionales durante la etapa de compilación. Estos archivos fuente no tienen que ser archivos Java; puedes generar cualquier tipo de descripción, metadatos, documentación, recursos, o cualquier otro tipo de archivos, según las anotaciones en tu código fuente.
El procesamiento de anotaciones se utiliza activamente en muchas bibliotecas Java ubicuas, por ejemplo, para generar metaclases en QueryDSL y JPA, para aumentar las clases con código redundante en la biblioteca Lombok. Es importante destacar que la limitación de la API de procesamiento de anotaciones es que solo se puede usar para generar nuevos archivos, no para cambiar los existentes.
La notable excepción es la biblioteca Lombok, que utiliza el procesamiento de anotaciones como un mecanismo de arranque para incluirse en el proceso de compilación y modificar el AST a través de algunas API internas del compilador. Esta técnica poco convencional no tiene nada que ver con el propósito previsto del procesamiento de anotaciones y, por lo tanto, no se discutirá en este artículo.
API de Procesamiento de Anotaciones
El procesamiento de anotaciones se realiza en múltiples rondas. Cada ronda comienza con el compilador buscando anotaciones en los archivos fuente y eligiendo los procesadores de anotaciones adecuados para estas anotaciones. Cada procesador de anotaciones, a su vez, se llama sobre los fuentes correspondientes.
Si se generan archivos durante este proceso, se inicia otra ronda utilizando los archivos generados como entrada. Este proceso continúa hasta que no se generan nuevos archivos durante la etapa de procesamiento.
La API de procesamiento de anotaciones se encuentra en el paquete javax.annotation.processing
. La interfaz principal que debes implementar es la interfaz Processor
, que tiene una implementación parcial en forma de la clase AbstractProcessor
. Esta clase es la que vamos a extender para crear nuestro propio procesador de anotaciones.
Configuración del Proyecto
Para demostrar las posibilidades del procesamiento de anotaciones, desarrollaremos un procesador simple para generar constructores de objetos fluidos para clases anotadas.
Dividiremos nuestro proyecto en dos módulos de Maven. Uno de ellos, el módulo de annotation-processor
, contendrá el propio procesador junto con la anotación, y el otro, el módulo de annotation-user
, contendrá la clase anotada. Este es un caso de uso típico del procesamiento de anotaciones.
Las configuraciones para el módulo annotation-processor
son las siguientes. Vamos a utilizar la biblioteca de auto-service de Google para generar un archivo de metadatos del procesador que se discutirá más adelante, y el maven-compiler-plugin
ajustado para el código fuente de Java 8. Las versiones de estas dependencias se extraen a la sección de propiedades.
<properties>
<auto-service.version>1.0-rc2</auto-service.version>
<maven-compiler-plugin.version>
3.12.1
</maven-compiler-plugin.version>
</properties>
<dependencies>
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>${auto-service.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
El módulo annotation-user
con las fuentes anotadas no necesita ningún ajuste especial, excepto que añade una dependencia en la sección de dependencias del módulo de procesamiento de anotaciones:
<dependency>
<groupId>com.baeldung</groupId>
<artifactId>annotation-processing</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
Definiendo una Anotación
Supongamos que tenemos una clase POJO simple en nuestro módulo annotation-user
con varios campos:
public class Person {
private int age;
private String name;
// getters y setters …
}
Queremos crear una clase de ayudante de constructor para instanciar la clase Person
de manera más fluida:
Person person = new PersonBuilder()
.setAge(25)
.setName("John")
.build();
Esta clase PersonBuilder
es una opción obvia para la generación, ya que su estructura está completamente definida por los métodos setters de Person
.
Vamos a crear una anotación @BuilderProperty
en el módulo annotation-processor
para los métodos setter. Nos permitirá generar la clase Builder
para cada clase que tenga sus métodos setter anotados:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface BuilderProperty {
}
La anotación @Target
con el parámetro ElementType.METHOD
asegura que esta anotación solo se pueda poner en un método. La política de retención SOURCE
significa que esta anotación solo está disponible durante el procesamiento de la fuente y no está disponible en tiempo de ejecución.
La clase Person
con propiedades anotadas con la anotación @BuilderProperty
se verá así:
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 …
}
Implementando un Processor
6.1. Creando una Subclase de AbstractProcessor
Comenzaremos extendiendo la clase AbstractProcessor
dentro del módulo de annotation-processor
.
Primero, debemos especificar las anotaciones que este procesador es capaz de procesar, así como la versión del código fuente admitida. Esto se puede hacer implementándolo en los métodos getSupportedAnnotationTypes
y getSupportedSourceVersion
de la interfaz Processor
o anotando tu clase con @SupportedAnnotationTypes
y @SupportedSourceVersion
.
La anotación @AutoService
es parte de la biblioteca auto-service
y permite generar el metadato del procesador que se explicará en las secciones siguientes.
@SupportedAnnotationTypes("com.baeldung.annotation.processor.BuilderProperty")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@AutoService(Processor.class)
public class BuilderProcessor extends AbstractProcessor {
@Override
public boolean process(Set extends TypeElement> annotations, RoundEnvironment roundEnv) {
return false;
}
}
Puedes especificar no solo los nombres de las clases de anotaciones concretas, sino también comodines, como "com.baeldung.annotation.*"
para procesar anotaciones dentro del paquete com.baeldung.annotation
y todos sus subpaquetes, o incluso "*"
para procesar todas las anotaciones.
El único método que debemos implementar es el método process
que realiza el propio procesamiento. Este es llamado por el compilador para cada archivo fuente que contiene las anotaciones coincidentes.
Las anotaciones se pasan como el primer argumento Set extends TypeElement> annotations
, y la información sobre la ronda actual de procesamiento se pasa como el argumento RoundEnvironment roundEnv
.
El valor de retorno boolean
debe ser true
si tu procesador de anotaciones ha procesado todas las anotaciones pasadas y no deseas que sean pasadas a otros procesadores de anotaciones en la lista.
6.2. Recopilando Datos
Nuestro procesador aún no hace nada útil, ¡así que vamos a llenarlo de código!
Primero, necesitaremos iterar a través de todos los tipos de anotaciones que se encuentran en la clase; en nuestro caso, el conjunto de annotations
tendrá un solo elemento correspondiente a la anotación @BuilderProperty
, incluso si esta anotación ocurre múltiples veces en el archivo fuente.
@Override
public boolean process(Set extends TypeElement> annotations, RoundEnvironment roundEnv) {
for (TypeElement annotation : annotations) {
Set extends Element> annotatedElements = roundEnv.getElementsAnnotatedWith(annotation);
// …
}
return true;
}
En este código, utilizamos la instancia RoundEnvironment
para recibir todos los elementos anotados con la anotación @BuilderProperty
. En el caso de la clase Person
, esos elementos corresponden a los métodos setName
y setAge
.
El usuario de la anotación @BuilderProperty
podría erróneamente anotar métodos que no son realmente setters. El nombre del método setter debe comenzar con set
, y el método debe recibir un solo argumento. Así que vamos a separar lo incorrecto.
En el siguiente código, usamos el recolector Collectors.partitioningBy()
para dividir los métodos anotados en dos colecciones: los setters correctamente anotados y otros métodos erróneamente anotados:
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);
Aquí usamos el método Element.asType()
para recibir una instancia de la clase TypeMirror
, que nos da cierta capacidad de introspección de tipos, incluso si solo estamos en la etapa de procesamiento de la fuente.
Deberíamos advertir al usuario sobre los métodos anotados incorrectamente, así que usemos la instancia Messager
accesible desde el campo protegido AbstractProcessor.processingEnv
. Las siguientes líneas generarán un error para cada elemento anotado erróneamente durante la etapa de procesamiento de origen:
otherMethods.forEach(element ->
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
"@BuilderProperty debe aplicarse a un método setXxx "
+ "con un solo argumento", element));
Por supuesto, si la colección de setters está vacía, no hay sentido en continuar la iteración del conjunto de elementos de tipo actual:
if (setters.isEmpty()) {
continue;
}
Si la colección de setters tiene al menos un elemento, vamos a usarla para obtener el nombre de la clase completamente calificada del elemento contenedor, que en el caso del método setter parece ser la clase fuente misma:
String className = ((TypeElement) setters.get(0).getEnclosingElement()).getQualifiedName().toString();
El último conjunto de información que necesitamos para generar una clase de builder es un mapa entre los nombres de los setters y los nombres de sus tipos de argumento:
Map setterMap = setters.stream().collect(Collectors.toMap(
setter -> setter.getSimpleName().toString(),
setter -> ((ExecutableType) setter.asType()).getParameterTypes().get(0).toString()
));
6.3. Generando el Archivo de Salida
Ahora tenemos toda la información que necesitamos para generar una clase de builder: el nombre de la clase fuente, todos sus nombres de setter y sus tipos de argumento.
Para generar el archivo de salida, utilizaremos la instancia Filer
proporcionada nuevamente por el objeto en la propiedad protegida AbstractProcessor.processingEnv
:
JavaFileObject builderFile = processingEnv.getFiler()
.createSourceFile(builderClassName);
try (PrintWriter out = new PrintWriter(builderFile.openWriter())) {
// escritura del archivo generado a out …
}
El código completo del método writeBuilderFile
se proporciona a continuación. Solo necesitamos calcular el nombre del paquete, el nombre completamente calificado de la clase builder, y los nombres de clase simples para la clase fuente y la clase builder. El resto del código es bastante sencillo.
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. Ejecutando el Ejemplo
Para ver la generación de código en acción, debes compilar ambos módulos desde la raíz común o primero compilar el módulo annotation-processor
y luego el módulo annotation-user
.
La clase generada PersonBuilder
se puede encontrar dentro del archivo annotation-user/target/generated-sources/annotations/com/baeldung/annotation/PersonBuilder.java
y debería verse así:
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. Métodos Alternativos para Registrar un Procesador
Para utilizar tu procesador de anotaciones durante la etapa de compilación, tienes varias opciones diferentes según tu caso de uso y las herramientas que utilices.
8.1. Usando la Herramienta de Procesamiento de Anotaciones
La herramienta apt
era una utilidad de línea de comandos especial para procesar archivos fuente. Era parte de Java 5, pero desde Java 7 fue desaprobada en favor de otras opciones y eliminada completamente en Java 8. No se discutirá en este artículo.
8.2. Usando la Clave del Compilador
La clave del compilador -processor
es una instalación estándar del JDK que permite aumentar la etapa de procesamiento de la fuente del compilador con tu propio procesador de anotaciones.
Ten en cuenta que el procesador en sí y la anotación tienen que ser ya compilados como clases en una compilación separada y estar presentes en el classpath, así que lo primero que debes hacer es:
javac com/baeldung/annotation/processor/BuilderProcessor
javac com/baeldung/annotation/processor/BuilderProperty
Luego, haces la compilación real de tus fuentes con la clave -processor
especificando la clase del procesador de anotaciones que acabas de compilar:
javac -processor com.baeldung.annotation.processor.BuilderProcessor Person.java
Para especificar varios procesadores de anotaciones a la vez, puedes separar sus nombres de clase con comas:
javac -processor package1.Processor1,package2.Processor2 SourceFile.java
8.3. Usando Maven
El maven-compiler-plugin
permite especificar procesadores de anotaciones como parte de su configuración.
Aquí hay un ejemplo de agregar un procesador de anotaciones para el plugin del compilador. También podrías especificar el directorio para colocar las fuentes generadas, usando el parámetro de configuración generatedSourcesDirectory
.
Ten en cuenta que la clase BuilderProcessor
ya debería estar compilada, por ejemplo, importada desde otro jar en las dependencias de construcción:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.12.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
<generatedSourcesDirectory>${project.build.directory}/generated-sources/</generatedSourcesDirectory>
<annotationProcessors>
<annotationProcessor>
com.baeldung.annotation.processor.BuilderProcessor
</annotationProcessor>
</annotationProcessors>
</configuration>
</plugin>
</plugins>
</build>
8.4. Agregando un Jar de Procesador al Classpath
En lugar de especificar el procesador de anotaciones en las opciones del compilador, puedes simplemente agregar un jar estructurado especialmente con la clase del procesador al classpath del compilador.
Para que el compilador lo recoja automáticamente, debe saber el nombre de la clase del procesador. Así que debes especificarlo en el archivo META-INF/services/javax.annotation.processing.Processor
como el nombre completamente calificado de la clase del procesador:
com.baeldung.annotation.processor.BuilderProcessor
También puedes especificar varios procesadores de este jar para recoger automáticamente separándolos con una nueva línea:
package1.Processor1
package2.Processor2
package3.Processor3
Si usas Maven para construir este jar y intentas colocar este archivo directamente en el directorio src/main/resources/META-INF/services
, encontrarás el siguiente 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
Esto se debe a que el compilador intenta usar este archivo durante la etapa de procesamiento de origen del módulo en sí cuando el archivo BuilderProcessor
no ha sido compilado. El archivo tiene que estar ya sea dentro de otro directorio de recursos y copiado al directorio META-INF/services
durante la fase de copiado de recursos de la construcción de Maven o (incluso mejor) generado durante la construcción.
La biblioteca de Google auto-service
, discutida en la siguiente sección, permite generar este archivo utilizando una simple anotación.
8.5. Usando la Biblioteca Google auto-service
Para generar el archivo de registro automáticamente, puedes usar la anotación @AutoService
de la biblioteca auto-service
de Google, así:
@AutoService(Processor.class)
public class BuilderProcessor extends AbstractProcessor {
// …
}
Esta anotación es procesada por el procesador de anotaciones de la biblioteca auto-service
. Este procesador genera el archivo META-INF/services/javax.annotation.processing.Processor
que contiene el nombre de la clase BuilderProcessor
.
9. Conclusión
En este artículo, hemos demostrado el procesamiento de anotaciones a nivel de código fuente utilizando el ejemplo de generación de una clase Builder para un POJO. También hemos proporcionado varias maneras alternativas de registrar los procesadores de anotaciones en tu proyecto.
Aprovecha esta técnica para automatizar tareas tediosas de generación de código y mejora la mantenibilidad de tu código Java. Experimenta con las bibliotecas y configuraciones presentadas para descubrir todo el potencial del procesamiento de anotaciones en Java.