Procesamiento de anotaciones en Java
Procesa anotaciones Java en tiempo de compilación con la API javax.annotation.processing para generar código o validar fuentes.
El procesamiento de anotaciones es un punto de extensión en javac. Se escribe una clase — un procesador de anotaciones — que el compilador invoca durante la compilación, le entrega los elementos que ha encontrado hasta ese momento y espera. El procesador puede hacer dos cosas útiles: validar el código anotado (emitir errores o advertencias a través del canal de diagnóstico de javac) o escribir nuevos archivos fuente que participen en la misma compilación.
Los frameworks que probablemente ya has utilizado funcionan gracias a este mecanismo:
- Lombok reescribe las clases anotadas para añadir getters, builders y
equals/hashCode. - Dagger / Hilt generan el cableado de inyección de dependencias en respuesta a
@Injecty@Module. - El metamodelo estático de Hibernate genera clases
Entity_para consultas Criteria con seguridad de tipos. - Auto-Service / Auto-Value generan entradas de servicio
META-INFy clases de valores sin boilerplate. - Micronaut / Quarkus generan el cableado del framework en tiempo de compilación en lugar de en el arranque.
La API del procesador vive en javax.annotation.processing y el modelo de lenguaje en javax.lang.model. Juntos permiten que javac albergue herramientas de terceros en tiempo de compilación.
La estructura de un procesador
Un procesador implementa javax.annotation.processing.Processor. En la práctica se extiende AbstractProcessor y se sobreescribe process(...):
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import java.util.Set;
@SupportedAnnotationTypes("com.example.Marker") // which annotations to handle
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class MarkerProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
for (Element e : roundEnv.getElementsAnnotatedWith(Marker.class)) {
processingEnv.getMessager().printMessage(
javax.tools.Diagnostic.Kind.NOTE,
"found @Marker on " + e.getSimpleName(),
e);
}
return true; // claim the annotation
}
}Las dos anotaciones en la clase declaran qué tipos de anotación quiere manejar este procesador y qué nivel de lenguaje tiene como objetivo. Ambas también pueden devolverse dinámicamente desde getSupportedAnnotationTypes() / getSupportedSourceVersion() si es necesario calcularlas.
process se llama por ronda. Cada ronda es un paso por las fuentes; si el procesador produce nuevos archivos, esos nuevos archivos se procesan a su vez en una ronda posterior. El bucle termina cuando ninguna ronda produce nuevos archivos.
El modelo de lenguaje: no es reflexión
La primera sorpresa: dentro de un procesador no se tiene Class<?>. Las clases que se están procesando todavía no han sido compiladas. En su lugar se trabaja con los tipos de javax.lang.model.element:
Element— cualquier cosa en el fuente: una clase, método, campo, parámetro, paquete.TypeElement— una clase, interfaz o enum (unElemental que se puede pedirgetQualifiedName()).ExecutableElement— un método o constructor.VariableElement— un campo, parámetro o variable local.TypeMirror— un tipo (como "el tipoList<String>"), distinto del elemento que lo declaró.
Estos reflejan los tipos de reflexión en tiempo de ejecución pero representan el código fuente, no las clases cargadas. Se pueden recorrer, consultar sus anotaciones y su ámbito contenedor. No se pueden invocar métodos sobre ellos, evaluar expresiones constantes arbitrariamente ni instanciarlos — todavía no existe ninguna instancia.
Para leer los valores de los elementos de una anotación se usa Element.getAnnotation(MyAnn.class) (devuelve un proxy, similar a la reflexión) o Element.getAnnotationMirrors() (devuelve la forma estructural, que es lo que se necesita cuando el valor del elemento contiene una referencia a Class de un tipo que también está siendo compilado en esa misma ronda).
Registrar el procesador
El compilador necesita encontrar el procesador. Hay dos formas:
- Archivo de Service Loader. Se coloca un archivo llamado
META-INF/services/javax.annotation.processing.Processoren el classpath del procesador cuyo contenido es el nombre completamente cualificado de la clase del procesador, uno por línea. Esto es lo que herramientas comoauto-servicede Google generan automáticamente. - Bandera
-processor. Se pasa-processor com.example.MarkerProcessorajavac(o se configura en la herramienta de construcción — la configuraciónannotationProcessorde Gradle, o<annotationProcessorPaths>de Maven).
En Maven y Gradle la convención es mantener el procesador en su propio módulo y depender de él desde el módulo principal con annotationProcessor (Gradle) / <scope>provided</scope> (Maven). El procesador solo se ejecuta durante la compilación y no se incluye en el artefacto final.
Generar archivos
Hay dos tipos de salida posibles:
- Archivos fuente — escritos mediante
processingEnv.getFiler().createSourceFile(name). El resultado es unJavaFileObjectcuyoopenWriter()se rellena con código fuente. El nuevo archivo se compila en la siguiente ronda. - Archivos de recursos — escritos mediante
getFiler().createResource(...)para cualquier cosa que acabe en el classpath en tiempo de ejecución (por ejemplo, registros de servicios).
El patrón consiste en derivar el paquete y el nombre de la nueva clase a partir del elemento anotado y luego crear el fuente como un String:
TypeElement cls = ...; // the annotated class
String pkg = elementUtils.getPackageOf(cls).getQualifiedName().toString();
String genName = cls.getSimpleName() + "Generated";
JavaFileObject src = filer.createSourceFile(pkg + "." + genName, cls);
try (Writer w = src.openWriter()) {
w.write("package " + pkg + ";\n");
w.write("public class " + genName + " {\n");
w.write(" public static String origin() { return \"" + cls.getSimpleName() + "\"; }\n");
w.write("}\n");
}Un procesador real normalmente usa un generador de código como JavaPoet (que expone un constructor de AST tipado) en lugar de concatenación de cadenas. La mecánica es idéntica; JavaPoet simplemente hace el fuente más legible.
Errores, advertencias y notas
Un procesador reporta diagnósticos a través de Messager:
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@Marker may only annotate top-level classes",
element);Kind.ERROR hace fallar la construcción en la posición del fuente de ese elemento. WARNING, MANDATORY_WARNING y NOTE son los niveles inferiores. Siempre se debe pasar el argumento Element cuando sea posible — le proporciona al usuario una ubicación de fuente en la que se puede hacer clic en lugar de una línea de registro de construcción.
Consideraciones sobre compilación incremental
Los procesadores de anotaciones son una causa conocida de ralentizaciones en la construcción. Hay dos razones:
- Pueden ser no incrementales: si al procesador no se le indica qué fuentes reprocesar, la herramienta de construcción reprocesa todo cuando cambia cualquier fuente.
- Pueden bloquear el paralelismo: las rondas son secuenciales.
Gradle introdujo las categorías de procesadores isolating y aggregating para permitir que los procesadores participen en la compilación incremental. Un procesador que produce un archivo generado por cada fuente anotada (Dagger hace esto para @Component) puede declararse "isolating" y Gradle lo vuelve a ejecutar solo para las fuentes modificadas. Los procesadores aggregating — aquellos que examinan todos los elementos anotados para producir un único archivo de registro — se vuelven a ejecutar cuando cambia cualquier fuente anotada. Se debe elegir la categoría del procesador de forma honesta; la contrapartida es corrección frente a velocidad.
Un ejemplo completo: un sustituto en tiempo de ejecución para el procesamiento en tiempo de compilación
El procesamiento real de anotaciones requiere una construcción multimodular, el punto de extensión de javac y un archivo de servicio — nada de lo cual cabe en un único programa. La mejor demostración alternativa es un sustituto en tiempo de ejecución que hace el mismo tipo de trabajo: recorre clases anotadas, las valida y escribe archivos fuente en un directorio temporal, tal como lo haría un procesador en tiempo de compilación.
Lo que se puede extraer de la ejecución:
- El procesador recorrió tres clases y actuó sobre dos — exactamente la forma de
RoundEnvironment.getElementsAnnotatedWith(Generate.class)en un procesadorjavacreal. La tercera clase fue omitida silenciosamente porque su anotación no estaba presente. Este es el modelo: un procesador consume un conjunto de elementos por ronda y solo trabaja con los que le interesan. - Cada archivo generado llevaba el paquete de la clase fuente y un nombre derivado. En
javax.lang.modelse calcula el paquete desdeelementUtils.getPackageOf(typeElement).getQualifiedName()y el nombre desdetypeElement.getSimpleName(); aquí se usóClass.getPackageName()yClass.getSimpleName()como análogo. La forma se transfiere. - El elemento
suffixpermitió personalización por uso:AccountprodujoAccountGenerated,InvoiceprodujoInvoiceHelper. Los elementos de anotación son el control que se ofrece al usuario; los valores por defecto hacen el caso común conciso y los elementos con nombre dan control preciso cuando se necesita. - La validación simulada imprimió una línea
ERROR:para las clases abstractas. En un procesador real esto seríamessager.printMessage(Diagnostic.Kind.ERROR, "...", element)y la construcción fallaría en la ubicación del fuente del usuario. Los diagnósticos son una característica de primera clase, no una alternativa — deben usarse siempre que la anotación esté mal utilizada, nuncathrow. - El fuente generado no contiene nada complicado — un
List.of(...)de nombres de campo y un helperorigin(). Eso es lo típico. El valor de la generación en tiempo de compilación rara vez radica en la complejidad de la salida; radica en que la salida existe, antes de que el programa se ejecute, donde en tiempo de ejecución de lo contrario se necesitaría reflexión (y se pagaría su coste).
Cuándo recurrir a un procesador
Un procesador se justifica cuando:
- De otro modo estarías escribiendo el mismo boilerplate manualmente para cada clase anotada.
- El trabajo puede realizarse solo a partir de las firmas del fuente (sin necesidad del comportamiento real de la instancia).
- La alternativa en tiempo de ejecución usaría reflexión en cada llamada, y ese coste se acumula.
Un procesador es la herramienta equivocada cuando:
- Se quiere modificar una clase existente. Los procesadores estándar solo pueden añadir nuevos archivos fuente; no reescriben la clase anotada. (Lombok reescribe enganchándose al AST interno de
javac, lo cual no es oficial y es frágil.) - Los metadatos que se necesitan solo existen en tiempo de ejecución (ámbito de solicitud, identidad de usuario, configuración cargada desde disco).
- Una simple búsqueda reflectiva en el arranque haría el mismo trabajo en 50 líneas.
La decisión es la misma que para cualquier generación de código: más trabajo en tiempo de compilación, menos trabajo en tiempo de ejecución, y una construcción más difícil de depurar. Hay que sopesarlo con cuidado.
Fin de la parte 16
Con esto concluye la parte de Anotaciones del libro. Se ha cubierto qué es una anotación — metadatos puros, distintos del código que se ejecuta — luego el pequeño conjunto que proporciona la biblioteca estándar, las cinco meta-anotaciones que configuran las propias, la receta para declarar una anotación personalizada y finalmente la API de procesamiento en tiempo de compilación que los frameworks usan para actuar sobre las anotaciones durante la construcción.
El modelo mental que hay que llevarse: una anotación nunca hace nada por sí misma. Algo más la lee y decide actuar. Ese "algo más" puede ser el compilador (comprobaciones integradas), un procesador de anotaciones (generación de código en tiempo de compilación) o tu propio código mediante reflexión (frameworks en tiempo de ejecución). Cuando una anotación no se comporta como se espera, la primera pregunta siempre es: ¿quién se supone que la está leyendo?
La siguiente parte del libro es Reflexión — el lado en tiempo de ejecución de la API que ya has empezado a usar para leer anotaciones.