Java Annotation Processing

Nel lavoro di tutti i giorni può capitare di dover scrivere del codice boilerplate, senza valore aggiunto, magari ripetitivo o con classi che differiscono pochissimo tra loro. Se la logica è fatta in modo tale che non si riesce a mettere a fattor comune, magari conviene spostarsi ad un livello di programmazione dichiarativo con le annotations, interpretate tramite Reflection di Java, ma sappiamo però che quest’ultima è lenta. L’alternativa quindi? Il modello dichiarativo mi piace, ma non voglio usare la reflection: sarebbe bello quindi che qualcun altro generasse quel codice boilerplate a compile time al posto mio, magari partendo proprio dalle annotazioni. Gli Annotation Processors fanno proprio questo!

Le SPI colpiscono ancora

Gli Annotation Processors sono balzati agli occhi di tutti sulla piattaforma Android in primis con AndroidAnnotations, un framework che permette di annotare una classe Activity semplificandone notevolmente la scrittura del codice. Come è noto, la reflection in Android è proibitiva per la lentezza: AndroidAnnotations risolve il problema con la processazione delle annotazioni al momento della compilazione, generando una nuova Activity con tutto il codice che abbiamo “sott’inteso” con le annotazioni.

Ma come fa? Dalla versione 1.6, il compilatore Java sfrutta le SPI (che abbiamo visto in un post precedente) per esporre un servizio a cui agganciarsi in fase di compilazione: l’interfaccia di questo servizio è javax.annotation.processing.Processor. Implementando questa interfaccia, o meglio estendendo la classe astratta javax.annotation.processing.AbstractProcessor, possiamo intercettare la fase di compilazione e creare nuovi file, in particolare:

  • file sorgente: sono file .java che vengono immediatamente compilati;
  • class file: è possibile direttamente scrivere bytecode;
  • file di risorse: sono risorse generiche, come per esempio file di configurazione generati automaticamente.

Quello che l’annotation processing NON fa è modificare una classe esistente: per questo dovete rivolgervi a CGlib o JavaAssist che nascono appositamente per la manipolazione del bytecode.

Come fare ad attivare un annotation processor quindi? Proprio come tutte le SPI, basta creare un file

/META-INF/services/javax.annotation.processing.Processor

ed elencarvi gli annotation processors, uno per riga.

Il problema

Facciamo quindi un esempio pratico per capire come si usa e come si integra con Maven.

Abbiamo una lista di POJO che vogliamo serializzare in CSV: quello che vorrei è quindi annotare la classe, indicando la posizione dei campi nel CSV:

@CsvRendered
public class DomainModel {

    private String value1;
    private Date value2;
    private int value3;
    private float value4;

    @CsvPosition(3)
    public String getValue1() {
        return value1;
    }
    public void setValue1(String value1) {
        this.value1 = value1;
    }
    @CsvPosition(1)
    public Date getValue2() {
        return value2;
    }
    public void setValue2(Date value2) {
        this.value2 = value2;
    }
    @CsvPosition(0)
    public int getValue3() {
        return value3;
    }
    public void setValue3(int value3) {
        this.value3 = value3;
    }
    @CsvPosition(2)
    public float getValue4() {
        return value4;
    }
    public void setValue4(float value4) {
        this.value4 = value4;
    }
}

Quello che NON vorrei è invece scrivere il solito StringBuilder per separare i campi da virgola:

public class DomainModelCsvRenderer implements RowRenderer<DomainModel> {

    @Override
    public StringBuilder doRender(DomainModel model) {

        StringBuilder sb = new StringBuilder(100);
        sb.append(model.getValue3());
        sb.append(",");
        sb.append(model.getValue2());
        sb.append(",");
        sb.append(model.getValue4());
        sb.append(",");
        sb.append(model.getValue1());

        return sb;
    }
}

e il serializzatore che usa il renderer per ogni riga del csv:

public class DomainModelCsvSerializer extends CsvSerializer<DomainModel> {

    public DomainModelCsvSerializer() {
        super(new DomainModelCsvRenderer());
    }
}

Il progetto completo si trova su GitHub.

La soluzione

Grazie all’Annotation Processing è possibile generare automaticamente queste classi combinando i seguenti elementi:

  • annotazioni: definiscono il meta-modello sulla classe annotata.
  • template: è il template della classe Java che andrò a scrivere e compilare in un nuovo file. In questo progetto è stato usato Apache Velocity, ma va bene anche FreeMarker o qualsiasi altro motore di template. Un progetto interessante è JavaPoet di Square che permette di creare codice Java in modo programmatico. Personalmente preferisco i template, a meno che non siano troppo complessi.
  • processor: implementazione dell’SPI chiamata dal compilatore: si attiva quando vengono incontrate le annotazioni su cui il processor è registrato. Da qua si ha accesso alle classi annotate ed è possibile estrarne informazioni.

Come già detto, il compilatore espone l’interfaccia javax.annotation.processing.Processor come SPI, che possiamo implementare per processare la nostra annotazione @CsvRendered. Per semplicità, Java ci offre la classe astratta AbstractProcessor che implementa tutti i metodi dell’interfaccia ad eccezione di process che ha due argomenti:

  • Set<? extends TypeElement> annotations: sono i riferimenti alle annotazioni da processare
  • RoundEnvironment roundEnv: come la compilazione, anche la processazione viene fatta a round. Questo oggetto dà accesso alle informazioni sul round di processazione, come per esempio gli elementi annotati che è possibile processare in un determinato round.

Ecco quindi un esempio di implementazione:

@SupportedAnnotationTypes("it.cosenonjaviste.csv.annotations.CsvRendered")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class CsvRenderedProcessor extends AbstractProcessor {

  @Override
  public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
      processingEnv.getMessager()
                   .printMessage(Diagnostic.Kind.NOTE, 
                                 "Annotation processing: " + roundEnv);

      annotations.stream()
             .map(roundEnv::getElementsAnnotatedWith)
             .forEach(elements -> elements.stream()
                     .filter(element -> element.getKind() == ElementKind.CLASS)
                     .forEach(element -> {
                         TypeElement classElement = (TypeElement) element;

                         generateJavaSources(classElement);
                     }));
      return true;
  }
}

i cui elementi fondamentali sono:

  • @SupportedAnnotationTypes: insieme delle annotazioni da processare. Viene letta in reflection dal metodo AbstractProcessor#getSupportedAnnotationTypes
  • @SupportedSourceVersion: definisce l’ultima versione di Java supportata dall’annotation processor. Se non definita, il metodo AbstractProcessor#getSupportedSourceVersion la imposta a 1.6.
  • roundEnv.getElementsAnnotatedWith: restituisce tutti gli elementi annotati con le annotazioni specificate da @SupportedAnnotationTypes, accessibili tramite la variabile annotations.

Con l’aiuto degli Stream di Java 8 si riesce così in poche righe di codice a prendere gli elementi annotati e considerare solo quelli di tipo classe (ElementKind.CLASS), anche se dovrebbero esserlo tutti, in modo da fare un cast sicuro a TypeElement: per ognuno di essi verrà generata la classe CsvRenderer e CsvSerializer. Chi si occupa quindi di scrivere e compilare i file?

Semplificando il codice rispetto a quello su GitHub, il metodo generateJavaSources è responsabile della loro creazione:

private void generateJavaSources(TypeElement classElement) {
   String sourceFileName = classElement.getQualifiedName() + "CsvRenderer";
   try {
       JavaFileObject newSourceFile = 
          processingEnv.getFiler().createSourceFile(sourceFileName);
       try (Writer writer = newSourceFile.openWriter()) {
           writeBodyClass(classElement, writer);
       }
   } catch (IOException e) {
       processingEnv.getMessager()
                    .printMessage(Diagnostic.Kind.ERROR, e.getMessage(), element);
       e.printStackTrace();
   }
}

A questo punto entra in gioco l’ultimo attore, ovvero la variabile processingEnv di tipo javax.annotation.processing.ProcessingEnvironment. Da questa di ha accesso ad una serie di altri oggetti, tra cui:

  • javax.annotation.processing.Messenger: permette di loggare le operazioni in fase di compilazione.
  • javax.annotation.processing.Filer: è la classe principale che permette di creare file.
  • java.lang.model.util.Elements: classe di utilità per manipolare gli elements che possono essere tipi, metodi, variabili, annotazioni… in pratica tutti gli elementi che costituiscono una classe Java.
  • java.lang.model.util.Types: classe di utilità per manipolare i tipi.

La scrittura della classe in un file quindi è molto semplice: rimane da capire come fa writeBodyClass a scriverne il contenuto. Come accennato all’inizio del post, qua si possono fare diverse scelte. La strada del template la trovo molto chiara, infatti da questo file Velocity è facile immaginare che classe verrà fuori:

package $package;

import javax.annotation.Generated;
import it.cosenonjaviste.csv.api.RowRenderer;

/**
* @author CsvRenderedProcessor
*/
@Generated("it.cosenonjaviste.processors.CsvRenderedProcessor")
public class ${modelClass}CsvRenderer implements RowRenderer< $modelClass> {

    @Override
    public StringBuilder doRender($modelClass model) {

        StringBuilder sb = new StringBuilder(100);
        #foreach($method in $methods)
        sb.append(model.${method.name}());
        #if ($foreach.hasNext)
        sb.append(",");
        #end
        #end

        return sb;
    }
}

Per tutti i dettagli implementativi vi rimando alle classi CsvRendererGeneratorHelper e CsvSerializerGeneratorHelper.

Annotation Processing e Maven

Se state sviluppando un annotation processor per un progetto particolare è bene separarlo in un jar apposito, in modo da essere incluso come dipendenza provided, per evitare loop di compilazione inattesi. Il progetto su cui è basato questo post, per esempio, è organizzato in 3 moduli Maven:

csv-renderer-annotations
definisce le annotazioni @CsvRendered e @CsvPosition.
csv-renderer-processor
definisce l’annotation processor e ha come dipendenza csv-renderer-annotations in scope provided. Inoltre, per evitare l’attivazione dell’annotation processing nel progetto stesso, è bene disattivarlo. Ecco un estratto del pom.xml:
<dependencies>
   <dependency>
      <groupId>it.cosenonjaviste</groupId>
      <artifactId>csv-renderer-annotations</artifactId>
      <version>${project.version}</version>
      <scope>provided</scope>
   </dependency>
</dependencies>

<build>
   <plugins>
      <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-compiler-plugin</artifactId>
         <version>3.3</version>
         <configuration>
            <compilerArgument>-proc:none</compilerArgument>
         </configuration>
      </plugin>
   </plugins>
</build>
csv-renderer
è il progetto principale che usa le annotazioni e il codice generato, per questo ha come dipendenza i due precedenti:
<dependency>
    <groupId>it.cosenonjaviste</groupId>
    <artifactId>csv-renderer-processor</artifactId>
    <version>${project.version}</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>it.cosenonjaviste</groupId>
    <artifactId>csv-renderer-annotations</artifactId>
    <version>${project.version}</version>
    <scope>compile</scope>
</dependency>

Come tutte le SPI, non è necessario che CsvRenderedProcessor sia esplicitata perché viene eseguita automaticamente dal compilatore e crea le classi in un package con lo stesso nome di quello della classe annotata ma dentro /target/generated-sources/annotations (Eclipse invece le genera dentro /target/generated-sources/apt). Se vogliamo cambiare la cartella di destinazione, è necessario allora specificarla nel pom.xml in uno dei seguenti modi:

maven-compiler-plugin
con il compiler plugin di Maven possiamo specificare dove generare i file:
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.3</version>
    <configuration>
        <generatedSourcesDirectory>src/generated/java</generatedSourcesDirectory>
        <annotationProcessors>
            <annotationProcessor>
                it.cosenonjaviste.processors.CsvRenderedProcessor
            </annotationProcessor>
        </annotationProcessors>
    </configuration>
</plugin>

e volendo anche gli annotation processors da eseguire: se non specificati, vengono eseguiti tutti quelli trovati nel classpath, altrimenti si attivano solo quelli elencati nel tag annotationProcessors.

maven-processor-plugin
E’ un altro modo per attivare gli annotation processor: se usate questo plugin è bene disattivare l’annotation processing del compiler plugin.
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <compilerArgument>-proc:none</compilerArgument>
    </configuration>
</plugin>
<plugin>
    <groupId>org.bsc.maven</groupId>
    <artifactId>maven-processor-plugin</artifactId>
    <executions>
        <execution>
            <id>process</id>
            <goals>
                <goal>process</goal>
            </goals>
            <phase>generate-sources</phase>
            <configuration>
                <outputDirectory>src/generated/java</outputDirectory>
                <processors>
                    <processor>
                        it.cosenonjaviste.processors.CsvRenderedProcessor
                    </processor>
                </processors>
            </configuration>
        </execution>
    </executions>
</plugin>

Se decidiamo di non far scrivere i file nella cartella target, è bene aggiungere la nostra cartella tra quelle sorgente (con il plugin build-helper-maven-plugin) e tra quelle da ripulire (con il plugin maven-clean-plugin), altrimenti Eclipse non riuscirà a compilare.

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>build-helper-maven-plugin</artifactId>
    <version>1.5</version>
    <executions>
        <execution>
            <id>add-source</id>
            <phase>process-resources</phase>
            <goals>
                <goal>add-source</goal>
            </goals>
            <configuration>
                <sources>
                    <source>src/generated/java</source>
                </sources>
            </configuration>
        </execution>
    </executions>
</plugin>
<plugin>
    <artifactId>maven-clean-plugin</artifactId>
    <version>2.6.1</version>
    <configuration>
        <filesets>
            <fileset>
                <directory>src/generated</directory>
            </fileset>
        </filesets>
    </configuration>
</plugin>

Annotation Processing ed Eclipse

Mentre con IntelliJ non ho avuto nessun problema di sorta, non posso dire la stessa cosa di Eclipse. Come è noto, Eclipse non va molto d’accordo con Maven, e quando ci sono gli annotation processors di mezzo ancora meno. Il compilatore di Eclipse infatti non considera gli annotation processors per cui bisogna dirgli che esistono, ma solo se sono già impacchettati in un jar:
eclipse annotation processing config

che non fa al caso nostro adesso. Meglio quindi affidarsi a Maven (così il progetto è autoconsistente e può funzionare su tutte le IDE) configurando il pom.xml con il maven-processor-plugin e i plugin annessi come visto in precedenza: a questo punto possiamo dire ad Eclipse di delegare a Maven la processazione delle annotazioni. Nelle impostazioni generali di Eclipse, sotto Maven -> Annotation Processing, spostare il default come indicato:

eclipse annotation processing with maven

Se è stata specificata l’outputDirectory e aggiunta come cartella sorgente, Eclipse compilerà e genererà il codice in modo trasparente. Se non è stata specificata nessuna cartella, verrà aggiunta automaticamente la source folder target/generated-sources/apt contenente le classi generate.

In generale, indipendentemente dalla IDE, la cosa spiacevole che ho notato nella generazione automatica del codice, è che non è possibile specificare output folder diverse per annotation processor diversi: è possibile specificare una sola cartella in cui far confluire tutto il codice generato.

Considerazioni finali

Le potenzialità offerte dall’annotation processing sono estremamente interessanti, non solo per la generazione automatica del codice, che in certi casi può evitarci l’uso della reflection, ma può essere molto utile per altri scopi a compile time, come per esempio validare se certe annotazioni sono state usate in modo corretto. Possiamo per esempio creare un annotation processor che controlla se l’annotazione @CsvPosition è stata applicata ad un metodo che non restituisce void. Come fare a generare l’errore di compilazione? Basta scrivere nel processor un messaggio simile:

processingEnv.getMessager()
    .printMessage(Diagnostic.Kind.ERROR, String.format("%s is not returning a value", element), element);

dove element è proprio il riferimento al metodo annotato. Il codice completo della classe CsvPositionValidatorProcessor è su GitHub.

Un ultima considerazione sull’API che si ha a disposizione negli annotation processors (del package javax.lang.model): non è così facile come la reflection, anche perché è poco documentata.
C’è da tener conto che il vostro processor viene eseguito mentre il codice sta compilando, questo quindi porta a dei comportamenti che non siamo abituati ad attenderci: il modello del codice sorgente che il compilatore ha in memoria infatti rappresenta l’unico dominio a cui l’API può accedere. Accade quindi che lo stesso frammento di codice in certe situazioni funziona, in altre no. Questo accade soprattutto se l’annotazione che state analizzando non viene compilata al momento ma fa parte di qualche libreria già compilata in precedenza.

Per esempio, il progetto di test usato per scrivere il post è piuttosto lineare perché la compilazione dell’annotazione e la sua elaborazione da parte del processor sono nello stesso processo di compilazione: recuperare quindi l’annotazione @CsvPosition e il valore ad essa passato è molto semplice:

CsvPosition position = member.getAnnotation(CsvPosition.class);

dove member è un metodo della classe che stiamo analizzando. Se l’annotazione però fosse già stata compilata e quindi inclusa come libreria, la chiamata a getAnnotation probabilmente avrebbe restituito null, e si sarebbe dovuti ricorrere al metodo getAnnotationMirrors. Qua si apre un mondo che è al di fuori degli obiettivi di questo post; per capire meglio i problemi che si possono incontrare vi rimando al post “Getting class values from annotations in an annotationprocessor” che mi ha aiutato ad uscire dal caos!

Andrea Como

Sono un software engineer focalizzato nella progettazione e sviluppo di applicazioni web in Java. Presso OmniaGroup ricopro il ruolo di Tech Leader sulle tecnologie legate alla piattaforma Java EE 5 (come WebSphere 7.0, EJB3, JPA 1 (EclipseLink), JSF 1.2 (Mojarra) e RichFaces 3) e Java EE 6 con JBoss AS 7, in particolare di CDI, JAX-RS, nonché di EJB 3.1, JPA2, JSF2 e RichFaces 4. Al momento mi occupo di ECM, in particolar modo sulla customizzazione di Alfresco 4 e sulla sua installazione con tecnologie da devops come Vagrant e Chef. In passato ho lavorato con la piattaforma alternativa alla enterprise per lo sviluppo web: Java SE 6, Tomcat 6, Hibernate 3 e Spring 2.5. Nei ritagli di tempo sviluppo siti web in PHP e ASP. Per maggiori informazioni consulta il mio curriculum pubblico. Follow me on Twitter - LinkedIn profile - Google+