Las anotaciones de Java y ejemplo de procesador de anotaciones en tiempo de compilación

Escrito por picodotdev el .
java planeta-codigo
Enlace permanente Comentarios

Las anotaciones añadidas en Java 5 son muy utilizadas por múltiples librerías entre ellas Hibernate, Spring o Immutables. Desde Java 6 se ofrece una API para el procesamiento de las anotaciones en tiempo de compilación que permiten generar archivos de código fuente o emitir mensajes de error. Los procesadores de anotaciones son invocados por el compilador de Java permitiendo extender su comportamiento. En el artículo se muestra una implementación para generar clases que implementan el patrón Builder y otro para realizar comprobaciones.

Las anotaciones fueron añadidas en Java 5 como una forma de enriquecer con información el código. Tienen varios casos de uso, algunas están incorporadas en el propio JDK y las utiliza el compilador como @Override, otras son meramente informativas como @Deprecated, también se utilizan en la generación de documentación Javadoc con taglets, otras se procesan en tiempo de compilación para generar código o archivos dinámicamente al compilar, otras se procesan en tiempo de ejecución. Entre las anotaciones predefinidas incorporadas en el JDK hay algunas más.

En este artículo muestro cómo crear anotaciones para generar errores de compilación propios, como generar código dinámico en tiempo de compilación e integrarlo con un IDE como IntelliJ y con la herramienta de construcción Gradle.

Qué es una anotación en Java

Las anotaciones es una metainformación que se añade en el código fuente. Por sí mismas no hacen nada, es al procesarlas cuando se añade su comportamiento, sirve desde para añadir documentación, realizar comprobaciones de compilación, generar código o programar funcionalidades transversales.

Si se crea directamente una instancia de una clase anotada sin procesar las anotaciones esta no incluye el comportamiento que las anotaciones tienen intención de añadir, es el creador de la instancia el que ha de encargarse de añadirles el comportamiento en el momento de instanciarlas, el procesado de las anotaciones se puede hacer en tiempo de compilación o en tiempo de ejecución.

Este es el código básico de una anotación y su uso en una clase, su definición se realiza con la palabra @interface, se indica a que elementos del código fuente se pueden aplicar y el nivel de retención.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package io.github.picodotdev.blogbitix.annotationprocessor;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE })
public @interface Builder {
}
Builder.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package io.github.picodotdev.blogbitix.annotationprocessor;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE })
public @interface Value {
}
Value.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package io.github.picodotdev.blogbitix.javaannotations;

import java.util.Optional;

import io.github.picodotdev.blogbitix.annotationprocessor.Builder;
import io.github.picodotdev.blogbitix.annotationprocessor.Value;

@Builder
@Value
public class Foo {

    private String name;
    private Optional<String> color;

    public Foo() {
    }

    public Foo(String name, Optional<String> color) {
        this.name = name;
        this.color = color;
    }

    public void sayHello() throws InterruptedException {
        System.out.println("Hello, my name is " + name + " and my favorite color is " + color.orElse("black"));
    }
}
Foo.java

Las anotaciones tienen una sintaxis especial y definen atributos para en el momento de utilización proporcionar valores. Además poseen un nivel de retención según el cual la anotación está disponible:

  • Runtime: la información de las anotaciones quedan disponibles hasta en tiempo de ejecución y accesible mediante reflexión con los métodos de la clase Class.
  • Class: el compilador emite las anotaciones en tiempo de compilación en los archivos class de bytecode pero no están disponibles en tiempo de ejecución. Puede ser útil para herramientas que procesa los archivos de bytecode.
  • Source: las anotaciones son procesadas y descartadas en tiempo de compilación.

Las anotaciones también definen en que elementos del código fuente se pueden indicar:

  • ElementType.ANNOTATION_TYPE se puede aplicar en otra anotación.
  • ElementType.CONSTRUCTOR se puede aplicar en un constructor.
  • ElementType.FIELD se puede aplicar en una propiedad.
  • ElementType.LOCAL_VARIABLE se puede aplicar en una variable local.
  • ElementType.METHOD se puede aplicar en un método.
  • ElementType.PACKAGE se puede aplicar en un paquete.
  • ElementType.PARAMETER se puede aplicar en un parámetro.
  • ElementType.TYPE se puede aplicar en un tipo.

No es habitual tener que crear un procesador de anotaciones, Spring usa de forma extensiva las anotaciones procesándolas en tiempo de ejecución. Otra posibilidad es usar AspectJ para procesarlas procesarlas después de la compilación a bytecode o ByteBuddy que permite procesarlas en tiempo de compilación o ejecución. Otras librerías que usan anotaciones son las librerías Immutables, Lombok e Hibernate.

Procesador de anotaciones

El JDK ofrece una API para el desarrollo de procesadores de anotaciones. Un procesador de anotaciones es una clase que implementa la interfaz Processor, normalmente al crear un procesador de anotaciones se extiende de la clase AbstractProcessor.

Al definir el procesador de anotaciones se indica que anotaciones soporta el procesador y que nivel de código fuente soporta. El compilador de Java al realizar el proceso de compilación invoca a los procesadores de anotaciones proporcionando los elementos de código fuente que los contienen.

El método principal a implementar es el método process, el procesador ha de recopilar la información que necesite a través de los objetos proporcionados en el método y hacer uso de los servicios proporcionados en la clase ProcessingEnvironment. Para generar archivos de código fuente se utiliza el servicio Filer y para emitir mensajes de error el servicio Messager.

Con la infraestructura de servicios de Java se define el procesador de anotaciones creando un archivo de texto en la ubicación META-INF.services/javax.annotation.processing.Processor. El archivo contiene una línea por cada procesador de anotaciones de la librería. Los procesadores de anotaciones también se puede especificar de forma explícita con la opción -processor de javac.

1
2
io.github.picodotdev.blogbitix.annotationprocessor.BuilderProcessor
io.github.picodotdev.blogbitix.annotationprocessor.ValueProcessor
javax.annotation.processing.Processor

Generar código fuente

Utilizando el servicio Filer el procesador de anotaciones es capaz de generar nuevos archivos de código fuente. En este ejemplo se muestra como generar una clase que implementa el patrón Builder para la clase Foo anotada con la anotación @Builder. El procesador de anotaciones explora los elementos de la clase y con las propiedades que descubre genera el código fuente de la clase y los métodos adecuados de la clase Builder. El procesador de anotaciones en este caso emite el resultado mediante un PrintStream.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package io.github.picodotdev.blogbitix.annotationprocessor;

...

public class BuilderProcessor extends AbstractProcessor {

    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.RELEASE_11;
    }

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        return Set.of(Builder.class.getName());
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment environment) {
        Set<Element> annotatedElements = new HashSet<>();
        for (TypeElement annotation : annotations) {
            Set<? extends Element> elements = environment.getElementsAnnotatedWith(annotation);
            annotatedElements.addAll(elements);
        }

        for (Element element : annotatedElements) {
            try {
                generateBuilder((TypeElement) element);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        return true;
    }

    private void generateBuilder(TypeElement element) throws IOException {
        String name = element.getQualifiedName() + "Builder";
        JavaFileObject javaFileObject = processingEnv.getFiler().createSourceFile(name, element);
        try (PrintWriter pw = new PrintWriter(new OutputStreamWriter(javaFileObject.openOutputStream()))) {
            String packageName = processingEnv.getElementUtils().getPackageOf(element).getQualifiedName().toString();
            String className = element.getSimpleName().toString();
            String builderName = className + "Builder";
            Map<String, String> properties = getTypeProperties(element);
            String propertiesDeclaration = properties.keySet().stream().map(k -> "    private " + properties.get(k) + " " + k + ";").collect(Collectors.joining("\n"));
            String methodsDeclaration = properties.keySet().stream().map(k -> "    public " + builderName + " " + k + "(" + properties.get(k) + " " + k + ") {\n        this." + k + " = " + k + ";\n        return this;\n    }").collect(Collectors.joining("\n"));
            String buildMethod = "    public " + className + " build() {\n        return new " + className + "(" + properties.keySet().stream().collect(Collectors.joining(", ")) + ");\n    }";

            pw.println("package " + packageName + ";");
            pw.println();
            pw.println("public class " + builderName + " {");
            pw.println();
            pw.println(propertiesDeclaration);
            pw.println();
            pw.println(methodsDeclaration);
            pw.println();
            pw.println(buildMethod);
            pw.println("}");
        }
    }

    private Map<String, String> getTypeProperties(TypeElement type) {
        Map<String, String> properties = new LinkedHashMap<>();
        processingEnv.getElementUtils().getAllMembers(type).stream().filter(e -> e.getKind().equals(ElementKind.FIELD)).forEach(e -> {
            properties.put(e.getSimpleName().toString(), e.asType().toString());
        });
        return properties;
    }
}
BuilderProcessor.java

El resultado del procesador de anotaciones usando Gradle es un archivo de código fuente ubicado en build/generated/sources/annotationProcessor/java/main con el siguiente contenido. Las clases del proyecto pueden hacer referencia a esta clase generada como si existiese en el momento de compilación, los IDE también la detectan como cualquier otra clase.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package io.github.picodotdev.blogbitix.javaannotations;

public class FooBuilder {

    private java.lang.String name;
    private java.util.Optional<java.lang.String> color;

    public FooBuilder name(java.lang.String name) {
        this.name = name;
        return this;
    }
    public FooBuilder color(java.util.Optional<java.lang.String> color) {
        this.color = color;
        return this;
    }

    public Foo build() {
        return new Foo(name, color);
    }
}
FooBuilder.java

El uso de la clase builder es igual que cualquier otra clase del proyecto.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package io.github.picodotdev.blogbitix.javaannotations;

import java.util.Optional;

public class Main {

    public static void main(String[] args) throws Exception {
        System.out.println("Hola mundo");
        Foo foo = new FooBuilder().name("foo").color(Optional.of("red")).build();
        foo.sayHello();
    }
}
Main.java

Realizar comprobaciones de compilación

La anotación @Value es una anotación mediante la cual en tiempo de compilación se comprueba que una clase tiene redefinidos en este caso los métodos equals(), hashCode() y toString(). Es importante implementar correctamente los métodos equals(), hashCode() porque son usados por las colecciones, una implementación de estos que no cumple con los contratos de los métodos da lugar a potenciales errores y comportamientos anómalos. En caso de que la clase anotada no tenga redefinidos estos métodos se emite una advertencia de compilación.

Este procesador de anotaciones hace uso del servicio Messenger que posee métodos para emitir mensajes de error, de advertencia o de información. El procesador busca que métodos tiene la clase anotada y si no cumple la validación emite un error.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package io.github.picodotdev.blogbitix.annotationprocessor;

...

public class ValueProcessor extends AbstractProcessor {

    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.RELEASE_11;
    }

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        return Set.of(Value.class.getName());
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment environment) {
        Set<Element> annotatedElements = new HashSet<>();
        for (TypeElement annotation : annotations) {
            Set<? extends Element> elements = environment.getElementsAnnotatedWith(annotation);
            annotatedElements.addAll(elements);
        }

        for (Element element : annotatedElements) {
            try {
                List<? extends Element> executableEmentls = element.getEnclosedElements().stream().filter(t -> {
                    return t.getKind().equals(ElementKind.METHOD);
                }).collect(Collectors.toList());
                boolean hasHashCode = executableEmentls.stream().anyMatch(e -> {
                    return e.getSimpleName().toString().equals("hashCode");
                });
                boolean hasEquals = executableEmentls.stream().anyMatch(e -> {
                    return e.getSimpleName().toString().equals("equals");
                });
                boolean hasToString = executableEmentls.stream().anyMatch(e -> {
                    return e.getSimpleName().toString().equals("toString");
                });

                if (!hasHashCode || !hasEquals || !hasToString) {
                    processingEnv.getMessager().printMessage(Diagnostic.Kind.WARNING, "Class " + element.getSimpleName() + " should override hashCode, equals and toString methods");
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        return true;
    }
}
ValueProcessor.java

La clase Foo al estar anotada con la anotación Foo pero no redefinir los métodos equals, hashCode y toString heredados de Object el compilador y el procesador de la anotación genera un mensaje de advertencia en la compilación.

1
2
warning: Class Foo should override hashCode, equals and toString methods

System.out

Procesador de anotaciones en Gradle

Para que Gradle utilice los procesadores de anotaciones definidos en una librería hay que declararlo en la sección de dependencias mediante annotationProcessor.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
plugins {
    id 'java'
    id 'application'
}

repositories {
    mavenCentral()
    mavenLocal()
}

dependencies {
    annotationProcessor 'io.github.picodotdev.blogbitix:annotationprocessor:1.0'

    implementation 'io.github.picodotdev.blogbitix:annotationprocessor:1.0'
}

application {
    group = 'io.github.picodotdev.blogbitix'
    version = '1.0'
    sourceCompatibility = '11'
    mainClass = 'io.github.picodotdev.blogbitix.javaannotations.Main'
}
build-annotationprocessor.gradle

Esta dependencia se instala en el repositorio de Maven local haciendo uso del plugin maven-publish.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
plugins {
    id 'java'
    id 'java-library'
    id 'maven-publish'
}

repositories {
    mavenCentral()
    mavenLocal()
}

dependencies {
}

group = 'io.github.picodotdev.blogbitix'
version = '1.0'
sourceCompatibility = '11'

publishing {
    publications {
        maven(MavenPublication) {
            from components.java
        }
    }
}
build-javaannotations.gradle

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando siguiente comando:
./gradlew annotationprocessor:publishToMavenLocal && ./gradlew main:run

Comparte el artículo: