featured image Java Annotation Processors - Generating Code

October 01, 2018 / von Sebastian Sdorra / In Software Craftsmanship

Java Annotation Processors - Generating Code

+++ Der Originalartikel kann hier heruntergeladen werden: Zeitschriftenartikel, veröffentlicht in JAVA PRO 02/2018.+++

Im dritten und letzten Abschnitt dieser Artikelserie wird demonstriert wie man Source Code mit Hilfe eines Annotation Prozessors generieren kann, während im Einleitungsteil das Schreiben, Registrieren und die Nnutzung eines einfachen Annotation Prozessors und im zweiten Teil das Generieren von Konfigurationen im Vorderund stand.

Generating source code

In unserem Beispiel wollen wir für jede mit einer @JsonObject Annotation versehenen Klasse eine zusätzliche JsonWriter Klasse generieren. 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:

@JsonObject
public class Person {

  private String username;
  private String email;

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

  // getter
}

automatisch ein PersonJsonWriter erzeugt werden, der folgendermaßen aussieht:

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 würde die Länge des Artikels sprengen, da wir hierfür die ursprüngliche Klasse parsen müssten.

Annotierte Klassen finden

Als erstes müssen wir alle Klassen finden, die mit der JsonObject Annotation versehen wurden. Das unterscheidet sich in diesem Fall nicht von den beiden ersten Abschnitten, darum sparen wir uns diesmal das Code-Listing. Anschließend müssen wir für jede gefundene Klasse ein Scope-Objekt erzeugen, mit dem wir später eine Template-Engine füttern werden.

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 brauchen wir den Namen der annotierten Klasse und dessen Package. Um an den Namen des Packages zu kommen, müssen wir zunächst sicherstellen, dass es sich bei unserem annotierten Element um ein TypeElement handelt:

if (element instanceof TypeElement) {
}

Wenn das der Fall ist, können wir das TypeElement nach dessen übergeordneten Element fragen und dieses wiederum nach seinem Namen fragen:

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

Jetzt brauchen wir nur noch die Namen aller Getter-Methoden für unser Scope-Objekt. Dafür können wir ElementUtils des ProcessingEnvironmentverwenden:
processingEnv.getElementUtils().getAllMembers(typeElement)
Die getAllMembers -Methode gibt uns eine Liste aller Member Elemente unserer Klasse zurück. Aus dieser Liste müssen wir nur noch alle Elemente vom Typ METHOD, deren Name mit einem “get” anfängt, herausfiltern. Dafür lässt sich sehr gut die Stream API der Java Collections verwenden, die mit Java 8 eingeführt wurden:

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

Das Listing Zeile für Zeile erklärt:

  • Findet alle Member Elemente
  • Wandelt die Liste in einen Stream
  • Entfernt alle Elemente die nicht vom Typ Method sind
  • Extrahiert den Namen des Elements
  • Entfernt alle Namen die nicht mit “get” beginnen
  • Erstellt aus dem Stream wieder eine Liste Jetzt haben wir alle Informationen zusammen die wir brauchen um den JsonWriter zu erstellen.

JsonWriter schreiben

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

Filer filer = processingEnv.getFiler();
JavaFileObject fileObject = filer.createSourceFile(scope.getTargetClassNameWithPackage(), element);

Der createSourceFile Methode muss man den gewünschten Klassennamen und das annotierte Element übergeben, um ein JavaFileObject zu erhalten. Mit diesem JavaFileObject kann man anschließend einen Writer öffnen: 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/annotationsabgelegt).
Wir könnten nun den Source Code direkt mit dem Writer schreiben, 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ürde aber den Rahmen des Artikels sprengen, deshalb begnügen wir uns mit einer einfachen Template-Engine für unser Beispiel. Wir werden wir die Java Implementation der Template-Engine Mustache verwenden. Mustache Templates sind sehr einfach aufgebaut und die Syntax ist schnell erlernt. Um unser Beispiel zu verstehen, reicht es zu wissen, dass * mit dem Ausdruck {{sourceClassName}} auf die Getter-Methode getSourceClassName des Scope-Objektes zugegriffen wird * mittels {{#fields}}...{{/fields}} über die Collection der Fields Variable des Scope-Objektes iteriert wird und * {{^last}}...{{/last}} prüft, dass das Feld nicht das letzte Element in der Collection ist.

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:

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

Jetzt haben wir alles zusammen um den PersonJsonWriterzu generieren. Dafür kompilieren wir die, mit der @Json annotierten, Person Klasse mit unserem Annotation Prozessor im Classpath. Anschließend sollten wir die PersonJsonWriter class in the target/classes Verzeichnis finden. Verwenden können wir die Klasse wie folgt:

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:

{
  "class": "class com.cloudogu.blog.Person",
  "username": "tricia",
  "email": "tricia.mcmillian@hitchhicker.com"
}

Open Source Beispiele

Prominente Beispiele für Annotation Prozessoren die Quellcode generieren:

  • Hibernate Metamodel Generator generiert ein Metamodel 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 Entieties zu generieren.
  • Project Lombok verspricht, mit einer Reihe von Annotationen, den Boilerplate Code von Java Klassen automatisch zu generieren, z.B. Getter, Setter, hashCode der equals-Methoden.

Fazit

An den gezeigten Beispielen lässt sich sehr gut erkennen, dass Annotation Prozessoren ein sehr mächtiges Werkzeug sind. Annotation Prozessoren können, wie auf magische Weise, Quellcode und Konfigurationsdateien erzeugen. Aber mit großer Macht geht auch große Verantwortung einher. Wenn man nur den Quellcode eines Projektes betrachtet, 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 man auf den Annotation Prozessor verweisen der sie erzeugt hat. Für Java-Quellcode gibt es zudem eine spezielle Annotation für generierte Klassen (@Generated), die man verwenden kann um auf dessen Herkunft zu verweisen. Alle Beispiele und der Quellcode des Artikels, steht bei Github unter der MIT-Lizenz zur Verfügung.


Sebastian Sdorra
Sebastian Sdorra

- Software Development -

Als Experte für OpenSource und DevOps liegt ist die Automatisierung für ihn 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.