Java Annotation-Prozessoren

#Java #Annotation

Java Annotation-Prozessoren sind ein sehr mächtiges Tool im Werkzeugkasten eines Entwicklers. Mit ihnen lassen sich z.B. Informationen zur Build-Zeit protokollieren. Der Build kann mit einer Fehlermeldung abgebrochen werden, es können Konfigurationen und Dokumentationen erzeugt, Klassen verändert oder neu erstellt werden.

Nachfolgend betrachten wir die grundsätzliche Funktionsweise von Annotation-Prozessoren anhand eines kleinen Beispiels. Die beiden weiteren Abschnitte widmen sich dem Erzeugen von Konfigurationen und dem Generieren von Code.In einer Abstimmung mit rund 7.000 Teilnehmern hatte der Vorschlag Jakarta EE die Nase vorn. Weit über die Hälfte, knapp 64% stimmten für die indonesische Hauptstadt als Namensgeber. Jakarta liegt dazu noch auf der Insel Java. Vielen Abstimmungsteilnehmern lag das offenbar näher als das etwas sperrig klingende Enterprise Profile

Annotation-Prozessoren werden während des Builds vom Compiler (javac) aufgerufen, wenn eine der konfigurierten Annotationen gefunden wurde. Dabei kann ein Annotation-Prozessor bestimmen, über welche Annotationen er benachrichtigt wird. Das können einzelne, mehrere oder alle Annotationen sein. Wenn der Compiler eine Annotation findet, wird überprüft, ob für diese ein Prozessor registriert wurde. Ist dies der Fall, wird er ausgeführt. An diesem Punkt kann der Annotation-Prozessor seine Arbeit verrichten und entscheiden, ob für die gefundene Annotation noch weitere Prozessoren aufgerufen werden dürfen. Im ersten Abschnitt des Artikels wird ein einfacher Annotation-Prozessor erstellt, der eine Log-Meldung während des Kompilierens ausgibt. Dieser Annotation-Prozessor wird zunächst nur mit Hilfe des Java-Compilers verwendet. Anschließend wird aufgezeigt, wie ein einfacher Annotation-Prozessor mit einem Build-Tool wie Maven verwendet werden kann.

Ein einfacher Annotation-Prozessor

Annotation-Prozessoren müssen das Interface javax.annotation.processing.Processor implementieren. In den meisten Fällen empfiehlt es sich die Klasse javax.annotation.processing.AbstractProcessor zu erweitern, da sie nützliche Hilfsmethoden enthält. Der Beispiel-Annotation-Prozessor soll eine Meldung ausgeben, sobald eine bestimmte Annotation gefunden wurde. Dafür wird zuerst die Annotation com/cloudogu/blog/annotationprocessor/log/Log.java erstellt:

(Listing 1)
@Target({ElementType.TYPE})
public @interface Log {}

Die @Target Annotation mit dem Parameter ElementType.TYPE an der @Log Annotation bestimmt, dass @Log an allen Java-Typen (Klassen, Interfaces oder Enums) verwendet werden kann. Immer wenn Javac diese Annotation findet, soll eine Meldung auf der Konsole ausgegeben werden, die zeigt, welche Klasse die Annotation verwendet. Der Annotation-Prozessor com/cloudogu/blog/annotationprocessor/log/LogProcessor.java zu der @Log Annotation sieht wie folgt aus:

(Listing 2)
@SupportedAnnotationTypes(„com.cloudogu.blog.annotationprocessor.log.Log“)
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class LogProcessor extends AbstractProcessor {
      @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
      for ( TypeElement annotation : annotations ) {
       for ( Element element : roundEnv.getElementsAnnotatedWith(annotation) ) {
         processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, „found @Log at “ + element);
       }
      }
      return true;
    }
}

Die @SupportedAnnotationTypes Annotation bestimmt, für welche Annotationen der Prozessor aufgerufen wird. Es ist auch möglich * anzugeben. In diesem Fall wird der Prozessor für jede gefundene Annotation aufgerufen. Die @SupportedSourceVersion Annotation gibt an, welches die letzte Java-Version ist, mit der der Annotation-Prozessor umgehen kann. Wenn der Annotation-
Prozessor mit einer neueren Java-Version verwendet wird, so wird eine Warnung ausgegeben, dass der Prozessor diese Java-Version nicht unterstützt. Schließlich muss die process Methode des AbstractProcessor implementieren. Dieser Methode werden zwei Werte übergeben:

• Ein Set von java.lang.model.element.TypeElement – dieses Set enthält alle gefundenen Annotationen,
javax.annotation.processing.RoundEnvironment – mit diesem Objekt können die gefundenen, annotierten Elemente untersucht werden.

Wenn die process Methode true zurückgibt, werden keine weiteren Annotation-Prozessoren für die gefundene Annotation aufgerufen. Wenn sie false zurückgibt, können weitere Annotation-
Prozessoren für diese Annotation benachrichtigt werden. Erweitert man den AbstractProcessor, kann man außerdem auf die Variable processingEnv vom Typ javax.annotation.processing.ProcessingEnvironment zugreifen. Processing Environment erlaubt es, auf die Umgebung des Compilers zuzugreifen, um zum Beispiel den Build-Prozess abzubrechen oder eine Meldung auf der Konsole auszugeben. In gezeigtem Beispiel wird wie folgt vorgegangen:

1. Erst über das Set der gefundenen Annotationen interieren
  for ( TypeElement annotation : annotations ) {
2. Anschließend mit Hilfe des RoundEnvironment für jede Annotation, die Elemente die mit dieser Annotation annotiert wurden, suchen.
for ( Element element : roundEnv.getElementsAnnotatedWith(annotation) ) {
3. Schließlich alle gefundenen Elemente, mit dem Processing Environment, als Information auf der Konsole ausgeben.
  processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, „found @Log at “ + element);

Registrierung

Damit der Compiler den Annotation-Prozessor finden kann, muss dieser registriert werden. Dies erfolgt über den mit Java 6 eingeführten ServiceLoader. Dafür muss eine Datei unter META-INF/services/javax.annotation.processing.Processor angelegt werden, die den vollständigen Namen des Annotation-Prozessors enthält. In gezeigtem Beispiel wäre das: com.cloudogu.blog.annotationprocessor.log.LogProcessor.

Verwendung

Um den Prozessor verwenden zu können, wird eine Klasse benötigt, die mit @Log annotiert ist, zum Beispiel (com/cloudogu/log/annotationprocessor/sample/Hello.java):

(Listing 3)
@Log
public class Hello {
 public static void main(String[] args) {
   System.out.println(„Hello“);
 }
}

Nach dem Anlegen der Beispiel-Klasse kann kompiliert und der Annotation-Prozessor getestet werden. Zuerst werden die Annotation und der Prozessor kompiliert: javac –cp . -proc:none com/cloudogu/blog/annotationprocessor/log/*.java
Der Parameter -proc:none deaktiviert alle Annotation-Prozessoren. Das ist wichtig, da der Compiler die Registrierung (die Datei unter META-INF/services) bereits findet, aber der Prozessor noch nicht kompiliert ist. Das würde zu einem Fehler führen. Jetzt kann die Test-Klasse kompiliert und damit der Annotation-Prozessor getestet werden: javac –cp . com/cloudogu/blog/annotationprocessor/sample/*.java

Nun sollte der Compiler den Annotation-Prozessor aufrufen und folgende Zeile sollte auf der Konsole zu sehen sein:

Note: found @Log at com.cloudogu.blog.annotationprocessor.sample.Hello

Annotation-Prozessoren können auch mit den gängigen Build-Tools und IDEs verwendet werden. Um Annotation-Prozessoren zum Beispiel mit Maven zu verwenden, muss der Build in separate
Module aufgeteilt werden. Die Separierung muss erfolgen, damit Maven den Annotation-Prozessor unabhängig und vor Verwendung kompilieren kann. Ein Beispiel mit Maven kann unter
Github eingesehen werden. Hierbei ist zu beachten, dass das maven-compiler-plugin in beiden Modulen konfiguriert werden muss. Im sample Modul sollen die Compiler-Warnings ausgegeben werden, deshalb muss showWarnings auf true gesetzt werden und im log Modul muss der Compiler wie oben erwähnt mit -proc:none ausgeführt werden (compilerArgs). Führt man nun mvn clean install im Parent-Modul aus, erscheint die erwartete Meldung im Output: [INFO] found @Log at com.cloudogu.blog.annotationprocessor.sample.Hello

Konfigurationsdateien generieren

Dieser Abschnitt widmet sich dem Erzeugen von Konfigurationsdateien für eine einfache Plugin-Bibliothek. Dafür wird ein Annotation-Prozessor geschrieben. Dieser Prozess schreibt alle Klassen, die mit einer @Extension Annotation versehen wurden, in eine XML-Datei. Zu dem vollständigen Namen der Klasse soll außerdem noch das Javadoc der Klasse mit in die XML-Datei geschrieben werden. Zusätzlich wird noch eine Klasse geschrieben, die es erlaubt, diese Dateien aus dem Classpath auszulesen.

Es ist auch möglich, alle Klassen mit einer @Extension Annotation zu finden, ohne einen Annotation-Prozessor zu verwenden. Dafür müssten aber alle Elemente des Classpath (Ordner und JAR-Dateien) geöffnet, jede Klasse geladen und mit Reflection überprüft werden, ob die Klasse auch die gesuchte Annotation enthält. Dieses Vorgehen ist aufwändiger, fehleranfälliger und deutlich langsamer.

Die Extension-Annotation

(Listing 4)
@Documented
@Target(ElementType.TYPE)
public @interface Extension {
}

Die @Extension Annotation ähnelt sehr der @Log Annotation aus dem ersten Abschnitt, mit Ausnahme der @Documented Annotation. @Documented sorgt dafür, dass die Annotation im Javadoc der annotierten Klasse auftaucht.

Der Extension Annotation-Prozessor

Der ExtensionProcessor sammelt zuerst alle Klassen, die mit der @Extension Annotation versehen wurden, in einem Set:

(Listing 5)
Set<ExtensionDescriptor> descriptors = new LinkedHashSet<>();
for ( TypeElement annotation : annotations ) {
   for ( Element extension : roundEnv.getElementsAnnotatedWith(annotation) ) {
     ExtensionDescriptor descriptor = createDescriptor(extension);
     descriptors.add(descriptor);
   }
}

Die createDescriptor Methode speichert dabei den Namen und das Javadoc der annotierten Klasse in einer eigenen Klasse namens ExtensionDescriptor. Der Name kann über den Typ des Elementes erfragt werden: extension.asType().toString(). Das Javadoc der Klasse kann über Elements des ProcessingEnvironments erfragt werden: processingEnv.getElementUtils().getDocComment(extension).trim().

Nachdem alle Extensions gesammelt wurden, kann die XML-Datei geschrieben werden. Damit die XML-Datei im Classpath verfügbar ist, muss sie in das richtige Verzeichnis geschrieben werden. Das Verzeichnis lässt sich über die Filer Klasse des Processing Environments herausfinden:

(Listing 6)
Filer filer = processingEnv.getFiler();
FileObject fileObject = filer.getResource(StandardLocation.CLASS_OUTPUT, „“, „extensions.xml“);
File extensionsFile = new File(fileObject.toUri());

Jetzt muss die Extensions-Datei nur noch mit Inhalt gefüllt werden. Dafür wird eine Wrapper-Klasse für die Extension Descriptor Klasse erstellt und annotiert beide mit JAXB-Annotationen.
Anschließend kann die Extensions-Datei mit Hilfe von JAXB geschrieben werden: JAXB.marshal(new ExtensionDescriptorWrapper(descriptors), file);

Mit dem ExtensionProcessor ist jetzt alles vorhanden, um während des Kompilierens alle Klassen, die mit einer @Extension Annotation versehen wurden, in einer Datei zu speichern. Das Ergebnis sollte wie folgt aussehen:

(Listing 7)
<?xml version=“1.0″ encoding=“UTF-8″ standalone=“yes“?>
<spl-extensions>
    <extensions>
      <className>com.cloudogu.blog.AhoiService</className>
      <description>Says ahoi to someone.</description>
    </extensions>
    <extensions>
      <className>com.cloudogu.blog.HelloService</className>
      <description>Says hello to someone.</description>
    </extensions>
</spl-extensions>

Diese Datei sollte sich im selben Verzeichnis wie die kompilierten Klassen befinden (bei Maven target/classes).

Extensions-Util

Um die Extensions zur Laufzeit wieder auszulesen, kann leicht eine Hilfsklasse geschrieben werden:

(Listing 8)
public static List<ExtensionDescriptor> getExtensions() throws IOException {
   List<ExtensionDescriptor> descriptors = new ArrayList<>();
   Enumeration<URL> extensionFiles = Thread.currentThread().
getContextClassLoader().getResources(LOCATION);
   while (extensionFiles.hasMoreElements()) {
     URL extensionFile = extensionFiles.nextElement();
     ExtensionDescriptorWrapper extensionDescriptorWrapper =
JAXB.unmarshal(extensionFile, ExtensionDescriptorWrapper.class);
     descriptors.addAll(extensionDescriptorWrapper.getExtensions());
   }
   return descriptors;
}

Mit dieser Methode werden alle Extension-XML-Dateien im Classpath gefunden. Außerdem werden in einer Liste alle Klassen gespeichert, die mit einer @Extension Annotation annotiert wurden. Da der ContextClassLoader des Threads verwendet wird, können sich die Extensions-XML-Dateien sogar in verschiedenen JAR-Dateien befinden. Wenn jetzt alle Extension-Klassen der Anwendung ausgeben werden sollen, kann folgender Code verwendet werden:

(Listing 9)
for (ExtensionDescriptor descriptor : Extensions.getExtensions())
{
   System.out.println(descriptor);
}

Das gesamte Beispiel kann unter part-2 des GitHub-Repositories gefunden werden.

Beispiele aus der Open-Source Welt

Ein prominentes Beispiel für einen Annotation-Prozessor, der Konfigurationsdateien generiert, ist der META-INF/services generator von Kohsuke Kawaguchi, der aus einer MetaInf Services Annotation die Konfiguration für den Java 6 Service Loader erzeugen kann. Ein weiteres Beispiel ist das Plugin-Framework von SCM-Manager 2.0.0. SCM-Manager hat in Version 1 noch Classpath-Scanning verwendet, um die Erweiterungen zu finden. Durch den Umstieg auf Annotation-Prozessoren konnte die Startzeit von SCM-Manager 2 drastisch verkürzt werden.

Source-Code generieren

Im letzten Teil des Artikels wird demonstriert, wie man Source-Code mit Hilfe eines Annotation-Prozessors generieren kann. In beschriebenem Beispiel soll für jede mit einer @Json-Object Annotation versehenen Klasse eine zusätzliche Json-Writer Klasse generiert werden. Die generierten JsonWriter Klassen sollen JSON für alle Getter Methoden der annotierten Klasse erzeugen. Damit ist es möglich, annotierte Klassen in das JSON-Format zu serialisieren. Konkret soll zu der Klasse Person automatisch ein PersonJsonWriter erzeugt werden:

(Listing 10)
@JsonObject
public class Person {

   private String username;
   private String email;

   public Person(String username, String email) {
     this.username = username;
     this.email = email;
   }

   // getter
}

(Listing 11)
public final class PersonJsonWriter {
   public static String toJson(Person object) {
     StringBuilder builder = new StringBuilder(„{„);

     builder.append(„\“class\“: \““);
     builder.append(object.getClass())
     builder.append(„\“,“);

     builder.append(„\“username\“: \““);
     builder.append(object.getUsername());
     builder.append(„\“,“);

     builder.append(„\“email\“: \““);
     builder.append(object.getEmail());
     builder.append(„\““);

     return builder.append(„}“).toString();
   }
}

Es wäre auch möglich, an die Person Klasse eine toJson Methode anzufügen, aber das wird an dieser Stelle vernachlässigt, da hierfür die ursprüngliche Klasse geparst werden müsste.

Annotierte Klassen finden

Zunächst müssen alle Klassen gefunden werden, die mit der JsonObject Annotation versehen wurden. Anschließend wird für jede gefundene Klasse ein Scope Objekt erzeugt, die später einer
Template-Engine übergeben wird.

(Listing 12)
public final class Scope {

   private String packageName;
   private String sourceClassName;
   private List<Field> fields = new ArrayList<>();

   Scope(String packageName, String sourceClassName) {
     this.packageName = packageName;
     this.sourceClassName = sourceClassName;
   }

   void addGetter(String getter) {
     String fieldName = getter.substring(3);
     char firstChar = fieldName.charAt(0);
     fieldName = Character.toLowerCase(firstChar) + fieldName.substring(1);
     fields.add(new Field(fieldName, getter));
   }

   // getter

   public static class Field {

     private String name;
     private String getter;

     private Field(String name, String getter) {
       this.name = name;
       this.getter = getter;
     }
     // getter
   }

}

Für das Scope Objekt wird der Name der annotierten Klasse und dessen Package benötigt. Um an den Namen des Packages zu kommen, muss zunächst sichergestellt werden, dass es sich bei
dem annotierten Element um ein TypeElement handelt:

(Listing 13)
if (element instanceof TypeElement) {
}

Wenn das der Fall ist, kann das TypeElement nach dessen übergeordneten Element gefragt und dieses wiederum nach seinem Namen gefragt werden:

(Listing 14)
private String getPackageName(TypeElement classElement) {
  return ((PackageElement) classElement.getEnclosingElement()).getQualifiedName().toString();
}

Jetzt werden nur noch die Namen aller Getter Methoden für das Scope Objekt benötigt. Dafür kann ElementsUtils des Processing Environments verwendet werden:

(Listing 15)
processingEnv.getElementUtils().getAllMembers(typeElement)

Die getAllMembers Methode gibt eine Liste aller Member-Elemente der Klasse zurück. Aus dieser Liste müssen nur noch alle Elemente vom Typ METHOD, deren Name mit get beginnt,
herausfiltert werden. Dafür lässt sich sehr gut die Stream-API der Java-Collections verwenden, die mit Java 8 eingeführt wurden:

(Listing 16)
processingEnv.getElementUtils().getAllMembers(typeElement)
  .stream()
  .filter(el -> el.getKind() == ElementKind.METHOD)
  .map(el -> el.getSimpleName().toString())
  .filter(name -> name.startsWith(„get“))
  .collect(Collectors.toList());

Zuerst werden alle Member Elemente gesucht und die Liste wird in einen Stream gewandelt. Dann werden alle Elemente entfernt, die nicht vom Typ Method sind. Die Namen des Elements werden extrahiert und es werden alle Namen entfernt, die nicht mit get beginnen. Zuletzt wird aus dem Stream wieder eine Liste erstellt.

Jetzt sind alle Informationen vorhanden, die benötigt werden, um den JsonWriter zu erstellen.

JsonWriter schreiben

Um den JsonWriter zu schreiben, kann abermals der Filer aus dem ProcessingEnvironment verwendet werden:

(Listing 17)
Filer filer = processingEnv.getFiler();
JavaFileObject fileObject = filer.createSourceFile(scope.getTarget-
ClassNameWithPackage(), element);

Der createSourceFile Methode muss der gewünschten Klassenname und das annotierte Element übergeben werden, um ein JavaFileObject zu erhalten. Mit diesem JavaFileObject kann man anschließend einen Writer öffnen:

(Listing 18)
Writer writer = fileObject.openWriter();

Dieser Writer schreibt dann eine Java-Datei in den Ordner des Packages in den Klassenpfad. Mit Maven werden von Annotation-Prozessoren erstellte Klassen unter target/generated-sources/annotations abgelegt.

Der Quellcode könnte nun direkt mit dem Writer geschrieben werden, aber man verliert schnell den Überblick durch das Escaping der Hochkommas. Eine andere Möglichkeit den Quellcode aus dem Scope Objekt zu erzeugen, ist JavaPoet. JavaPoet bietet eine Java-Builder-API um Java-Dateien zu erzeugen. Die Verwendung von JavaPoet wäre an dieser Stelle zu ausführlich, weshalb eine einfache Template-Engine als Beispiel dient. Für die Java Implementation der Template-Engine wird Mustache verwendet. Mustache-Templates sind sehr einfach aufgebaut und die Syntax
ist schnell erlernt.

Um das Beispiel zu verstehen, genügt es zu wissen, dass * mit dem Ausdruck {{sourceClassName}} auf die Getter-Methode getSourceClassName des Scope Objektes zugreift, * mittels {{#fields}}…{{/fields}} über die Collection der Fields Variable des Scope Objektes iteriert wird und * {{^last}}…{{/last}} prüft, ob das Feld nicht das letzte Element in der Collection ist.

(Listing 19)
package {{packageName}};

public final class {{targetClassName}} {

  public static String toJson({{sourceClassName}} object) {
    StringBuilder builder = new StringBuilder(„{„);

    {{#fields}}
    builder.append(„\“{{value.name}}\“: \““);
    builder.append(object.{{value.getter}}());
    builder.append(„\“{{^last}},{{/last}}“);
    {{/fields}}

    return builder.append(„}“).toString();
    }

}

Mit folgendem Code wird das Mustache-Template aus dem Classpath gelesen, mit dem Scope Objekt ausgeführt und in den Writer des JavaFileObjectes geschrieben:

(Listing 20)
MustacheFactory factory = new DefaultMustacheFactory();
Template template = factory.compile(„com/cloudogu/blog/jsonwriter.mustache“);
template.execute(writer, scope);

Jetzt sind alle Bestandteile vorhanden, um den PersonJson-Writer zu generieren. Dafür wird die mit der @Json annotierten Person Klasse mit dem Annotation-Prozessor im Classpath kompiliert. Anschließend sollte die PersonJsonWriter Klasse im target/classes Verzeichnis gefunden werden. Die Klasse kann wie folgt, verwendet werden:

(Listing 21)
Person person = new Person(„tricia“, „tricia.mcmillian@hitchhicker.com“);
String json = PersonJsonWriter.toJson(person);
System.out.println(json);

Das obere Listing sollte den folgenden JSON-String ausgeben:

(Listing 22)
{
  „class“: „class com.cloudogu.blog.Person“,
  „username“: „tricia“,
  „email“: „tricia.mcmillian@hitchhicker.com“
}

Open-Source Beispiele

Prominente Beispiele für Annotation-Prozessoren die Quellcodes generieren:
• Hibernate Metamodel-Generator generiert ein Metamodell aus JPA-Entities, um die JPA-Criteria-API typensicher zu verwenden.
• QueryDSL bietet ein Konzept, um Abfragen für Java-Entities in SQL-nahen Sprachen zu formulieren. Dabei werden Annotation-Prozessoren verwendet, um die API für die Abfragen aus den Entities zu generieren.
• Project Lombok verspricht, mit einer Reihe von Annotationen den Boilerplate-Code von Java-Klassen automatisch zu generieren, z.B. Getter, Setter, HashCode- oder Equals-Methoden.

Fazit:
An den gezeigten Beispielen lässt sich sehr gut erkennen, dass Annotation-Prozessoren sehr mächtige Werkzeuge sind. Annotation-Prozessoren können, fast wie von selbst, Quellcode und
Konfigurationsdateien erzeugen.

Aber mit großer Macht geht auch große Verantwortung einher. Wenn nur der Quellcode eines Projektes betrachtet wird, fehlen die generierten Dateien. Diese tauchen erst nach dem Kompilieren
auf und auch dann kann man auf den ersten Blick nicht erkennen, woher die generierten Dateien kommen und wie sie erzeugt wurden. Deshalb empfehlt es sich, in der Projektdokumentation
darauf hinzuweisen, dass Dateien beim Kompilieren erzeugt werden und in den Kommentaren der generierten Dateien sollte auf den Annotation-Prozessor, der sie erzeugt hat, verwiesen werden. Für Java-Quellcode gibt es zudem eine spezielle Annotation für generierte Klassen, die verwendet kann, um auf dessen Herkunft zu verweisen.

 

Sebastian Sdorra ist Experte für OpenSource und DevOps. Automatisierung ist ihm eine Herzensangelegenheit – ob IT-Infrastrukturen, Software-Deployments oder die Kaffeemaschine. Sebastian arbeitet als Head Developer am Cloudogu Ecosystem und dem SCM-Manager, um seine Mission zu erfüllen, das Leben anderer Entwickler zu erleichtern.

Carolyn Molski


Leave a Reply