W3docs

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 @Inject y @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-INF y 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 (un Element al que se puede pedir getQualifiedName()).
  • ExecutableElement — un método o constructor.
  • VariableElement — un campo, parámetro o variable local.
  • TypeMirror — un tipo (como "el tipo List<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:

  1. Archivo de Service Loader. Se coloca un archivo llamado META-INF/services/javax.annotation.processing.Processor en 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 como auto-service de Google generan automáticamente.
  2. Bandera -processor. Se pasa -processor com.example.MarkerProcessor a javac (o se configura en la herramienta de construcción — la configuración annotationProcessor de 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 un JavaFileObject cuyo openWriter() 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.

java— editable, runs on the server

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 procesador javac real. 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.model se calcula el paquete desde elementUtils.getPackageOf(typeElement).getQualifiedName() y el nombre desde typeElement.getSimpleName(); aquí se usó Class.getPackageName() y Class.getSimpleName() como análogo. La forma se transfiere.
  • El elemento suffix permitió personalización por uso: Account produjo AccountGenerated, Invoice produjo InvoiceHelper. 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ía messager.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, nunca throw.
  • El fuente generado no contiene nada complicado — un List.of(...) de nombres de campo y un helper origin(). 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.

Práctica

Práctica
Un procesador de anotaciones genera un archivo fuente 'Module' que agrega cada clase anotada con `@Service`. Las construcciones son lentas porque cualquier edición en cualquier fuente desencadena un reprocesamiento completo. ¿Cuál es la clasificación Gradle más apropiada?
Un procesador de anotaciones genera un archivo fuente 'Module' que agrega cada clase anotada con `@Service`. Las construcciones son lentas porque cualquier edición en cualquier fuente desencadena un reprocesamiento completo. ¿Cuál es la clasificación Gradle más apropiada?
Was this page helpful?