Zum Ende meiner Ausbildung zur Fachinformatikerin für Anwendungsentwicklung stand meine Abschlussarbeit an. Dabei fiel die Wahl auf ein Thema, das wir im Entwicklungs-Alltag bei queoflow wirklich gebrauchen können: Codegenerierung mit der Java-eigenen Technologie „Annotation Processing“ (kurz AP).

Besonders zu Beginn eines Projektes würde uns ein solcher Codegenerator extrem viel Arbeit abnehmen. Deswegen habe ich mich, mit der Betreuung unseres Teamleiters Ralph, an die Arbeit gemacht.

Ähnlich wie bei Reflection, lassen sich mit der Java-Technologie „Annotation Processing“ Meta-Daten auslesen und verarbeiten. Der große Unterschied zwischen den beiden Technologien ist der Zeitpunkt:

Reflection arbeitet erst zur Laufzeit, während AP bereits zur Compilezeit greift. Dies bringt einige Probleme mit sich (Testbarkeit), hat aber auch den Vorteil, dass so Dateien generiert werden können, die direkt in den laufenden Compileprozess mit eingebunden werden können.

AP arbeitet dabei in Runden. Erzeugt also Prozessor Dateien, so stehen die im nächsten Durchlauf (der nächsten Runde) sofort wieder zur Verfügung, da eine solche generierte Datei ja wieder Informationen zur Generierung weiterer Dateien beinhalten kann.

Beispiel: Erzeugen von Kindklassen

Im Folgenden möchte ich anhand eines Beispiels zeigen, wie man einen einfachen Generator selbst bauen kann. Am Ende des Artikels werde ich den Generator aus diesem Beispiel mit verlinken.

Der Beispielgenerator soll zu einer gegebenen Klasse erbende Kindklassen erstellen.

Dazu sind zunächst drei separate Maven-Projekte notwendig. Ich werde sie alle unter der groupId „com.queomedia.generator.example“ zusammenfassen, aber verschiedene artifactIds geben:

  • Annotation-Projekt (artifactId „annotation“)
  • AnnotationProcessor-Projekt (artifactId „annotation-processor“)
  • ein Beispielprojekt (artifactId „example“)

 

Annotation-Projekt („annotation“-artifact)

Im Annotation-Projekt wird eine Annotation „GenerateInheriting“ erstellt:

@Target(ElementType.TYPE)
public @interface GenerateInheriting {
    String[] classNames();
}

Damit ist das Annotation-Projekt bereits abgeschlossen.

AnnotationProcessor-Projekt („annotation-processor“-artifact)

Zunächst wird der Prozessor selbst erstellt:

@SupportedAnnotationTypes("com.queomedia.generator.example.annotation.GenerateInheriting")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class GenerateInheritingProcessor extends AbstractProcessor {

    private Messager messager;

    @Override
    public void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        this.messager = processingEnv.getMessager();
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        this.messager.printMessage(Kind.NOTE, "#########################");
        this.messager.printMessage(Kind.NOTE, "Start GenerateInheritingProcessor");

        return false;
    }
}

Wichtig ist hier die Annotation „@SupportedAnnotationTypes“. Darüber wird AP mitgeteilt, welche Annotation zu dem jeweiligen Prozessor gehört. Gültige Argumente sind die vollqualifizierten Namen der Annotationen.

Damit überprüft werden kann kann, ob der Prozessor prinzipiell registriert ist und aufgerufen wird, wird eine Lognachricht eingefügt.

Jetzt müssen noch zwei Dinge konfiguriert werden: Im Prozessor-artifact selbst muss das Processing deaktiviert werden. Bleibt es hier aktiviert, passiert folgendes: Das Annotation Processing findet vor der Compilezeit statt. Der Compiler würde versuchen, den Processor zu laden, bevor er überhaupt kompiliert ist, was zum Huhn-Ei-Problem führt. Die Compilierung kann nicht ablaufen, weil das Processing noch nicht durch ist, das Processing kann nicht ablaufen, weil keine compilierten Dateien vorliegen.

Um das Processing zu deaktivieren, wird das Maven-Compiler-Plugin in der pom.xml  eingebunden und konfiguriert. Außerdem wird noch eine Dependency zum Annotation-Projekt benötigt:

<dependencies>
    <dependency>
        <groupId>com.queomedia.generator.example</groupId>
        <artifactId>annotation</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>       
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.6.0</version>
            <configuration>
                <compilerArgument>-proc:none</compilerArgument>
                <source>1.8</source>
                <target>1.8</target>
                <encoding>UTF-8</encoding>
            </configuration>
        </plugin>
    </plugins>
</build>

Den Prozessor selbst wird in der Datei javax.annotation.processing.Processor registriert, die im Verzeichnis src/main/resources/META-INF/services liegen muss:

com.queomedia.generator.example.processor.GeneralInheritingProcessor

Möchte man mehrere Prozessoren registrieren, muss jeder einzelne in einer eigenen Zeile stehen, ohne Trennung durch ein Semikolon o.ä.

Example-Projekt („example“-artifact)

Damit ist der Prozessor einsatzbereit. Nun wird überprüft, ob alles korrekt konfiguriert ist und der Prozessor aufgerufen wird.

Dazu wird im Example-Projekt der Prozessor eingebunden und wieder das Maven-Compiler-Plugin konfiguriert :

<dependencies>
        <dependency>
            <groupId>com.queomedia.generator.example</groupId>
            <artifactId>annotation</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.6.0</version>
                <configuration>
                    <showWarnings>true</showWarnings>
                    <source>1.8</source>
                    <target>1.8</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
        </plugins>
    </build>

„showWarnings“ sorgt dafür, dass sämtliche Lognachrichten auch in der Konsole angezeigt werden. Ohne diesen Parameter würden beim Bauen in der Konsole nur Warnings und Errors erscheinen.

Jetzt wird eine Testklasse erstellt, die folgendermaßen annotiert wird:

@GenerateInheriting(classNames = { "SpecialUser" })
public class User {

}

Wenn jetzt das Example-Projekt über die Konsole gebaut wird, erhält man folgende Ausgabe:

output

Da der Prozessor jetzt prinzipiell aufgerufen wird und funktioniert, kann man sich die gewünschten Dateien generieren lassen.

Man kann sich dazu mit „roundEnv.getElementsAnnotatedWith(GenerateInheriting.class);“ erst alle Elemente holen lassen, die die Annotation besitzen und von dieser die gewünschten Klassennamen geben lassen und daraus mit Hilfe des Java-eigenen Filers eine Sourcedatei erzeugen:

@Override
public boolean process(final Set<? extends TypeElement> annotations, final RoundEnvironment roundEnv) {
    Set<? extends Element> annotatedElements = roundEnv.getElementsAnnotatedWith(GenerateInheriting.class);

    for (Element element : annotatedElements) {
        GenerateInheriting annotation = element.getAnnotation(GenerateInheriting.class);

        if (annotation.classNames() != null) {
            for (String childClazzName : annotation.classNames()) {
                PackageElement packageElement = (PackageElement) ele - ment.getEnclosingElement();
                try {
                    JavaFileObject jfo = this.filer.createSourceFile(childClazzName, packageElement);
                    String content = "public class " + childClazzName + "{}";
                    try (Writer writer = jfo.openWriter()) {
                        writer.write(content);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    return false;
}

Alle von AP generierten Dateien werden standardmäßig im Verzeichnis target/generated-sources/annotations abgelegt.

Nun ist das Dateierzeugen über Stringkonkatenierung / Stringbuilder nicht besonders gut lesbar. Sobald die Generierung über einzelne Codezeilen hinausgeht, wird das ganze sehr unübersichtlich und schwer wartbar. Ich persönlich benutze deswegen die Templating-Engine Freemarker, prinzipiell sollte es aber auch mit jeder anderen Engine funktionieren.

Dazu erstellt man sich ein Template und ändert den Code so ab, dass  dieses statt Stringkonkatenierung in der benutzt wird:

package ${packageName};

import ${packageName}.${parentClazzName};

public class ${childClazzName} extends ${parentClazzName} {

}

    @Override
    public boolean process(final Set<? extends TypeElement> annotations, final RoundEnvironment roundEnv) {
    
        Set<? extends Element> annotatedElements = roundEnv.getElementsAnnotatedWith(GenerateInheriting.class);
    
        for (Element element : annotatedElements) {
    
            GenerateInheriting annotation = element.getAnnotation(GenerateInheriting.class);
    
            if (annotation.classNames() != null) {
                for (String childClazzName : annotation.classNames()) {
                    PackageElement packageElement = (PackageElement) ele-ment.getEnclosingElement();
                    try {
                        Map<String, Object> modelMap = new HashMap<>();
                        modelMap.put("packageName", packageElement.getQualifiedName());
                        modelMap.put("parentClazzName", element.getSimpleName());
                        modelMap.put("childClazzName", childClazzName);
    
                        JavaFileObject jfo = this.filer.createSourceFile(childClazzName, packageElement);
    
                        Template template = this.freemarkerCfg.getTemplate("src/main/resources/templates/clazz.ftl");
                        try (Writer writer = jfo.openWriter()) {
                            template.process(modelMap, writer);
                        } catch (TemplateException e) {
                            e.printStackTrace();
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
        return false;
    }

Refactoring

Der Prozessor funktioniert damit wie gewünscht. Er ist nun allerdings weder getestet, noch testbar und verletzt das Prinzip der Single-Responsibility.

Deswegen trennt man nun den Prozessor in mehrere Serviceklassen auf: Eine zum Auslesen der annotierten Klassen und eine zum Generieren der Dateien. Der Prozessor delegiert die einzelnen Aufgaben nur noch an diese Services ab. Außerdem erstellt man eine Hilfsklasse, welche alle benötigten Informationen zu einer annotierten Klasse kapselt. Bei einem so einfachen Prozessor wie diesem, sieht eine Hilfsklasse auf den ersten Blick nach zu viel Inhalt aus. Der Aufwand lohnt sich aber, sobald die benötigten Informationen umfangreicher werden. Im Rahmen meiner Abschlussarbeit musste ich beispielsweise auch die Felder jeder Klasse auswerten und nach bestimmten Kriterien gruppieren. Das wäre über Listen oder Maps nicht mehr vernünftig handhabbar.

Nach dem Refactoren sieht das Ganze so aus:

Hilfsklasse AnnotatedClazz

public class AnnotatedClazz {

	private String packageName;
	
	private String parentClazzName;

	private String[] childClazzNames;

ElementParsingService:

public AnnotatedClazz createAnnotatedClazz(final Element clazz) {
    GenerateInheriting annotation = clazz.getAnnotation(GenerateInheriting.class);
    PackageElement enclosingPackage = (PackageElement) clazz.getEnclosingElement();
    return new AnnotatedClazz(enclosingPackage.getQualifiedName().toString(),
            clazz.getSimpleName().toString(),
            annotation.classNames());
}

SourceFileWritingService:

    public String createFiles(final List<AnnotatedClazz> annotatedClazzes) {
        Objects.requireNonNull(annotatedClazzes);

        if (freemarkerCfg == null) {
            init();
        }
        
        Template template;
        String templateName = "templates/clazz.ftl";
        try {
            template = freemarkerCfg.getTemplate(templateName);
        } catch (IOException e) {
            Logger.error(e.getMessage());
            throw new RuntimeException("Could not load template '"+ templateName + "'", e);
        }
        
        for (AnnotatedClazz clazz : annotatedClazzes) {

            Map<String, Object> modelMap = new HashMap<>();
            modelMap.put("packageName", clazz.getPackageName());
            modelMap.put("parentClazzName", clazz.getParentClazzName());

            for (String childClazzName : clazz.getChildClazzNames()) {
                modelMap.put("childClazzName", childClazzName);

                try {
                    JavaFileObject jfo = filer.createSourceFile(clazz.getPackageName() + "." + childClazzName);

                    try (Writer writer = jfo.openWriter()) {
                        template.process(modelMap, writer);
                        return jfo.getName();
                    } catch (TemplateException e) {
                        Logger.error(e.getMessage());
                    }
                } catch (IOException e) {
                    Logger.error(e.getMessage());
                }
            }
        }
        return null;
    }

Processor:

@Override
public boolean process(final Set<? extends TypeElement> annotations, final RoundEnvironment roundEnv) {

    Set<? extends Element> annotatedElements = roundEnv.getElementsAnnotatedWith(GenerateInheriting.class);

    List<AnnotatedClazz> parsedClazzes = new ArrayList<>();

    for (Element element : annotatedElements) {
        AnnotatedClazz clazz = elementParser.createAnnotatedClazz(element);
        parsedClazzes.add(clazz);
    }

    sourceFileWriter.createFiles(parsedClazzes);
    return false;
}

Automatisierte Unit-Tests

Bei queo nehmen automatisierte Tests einen sehr großen Stellenwert ein. AP greift zur Compilezeit, während Tests aber erst zur Laufzeit ausgeführt werden. Mit herkömmlichen Mitteln ist der Code zum Auslesen und Generieren damit nicht testbar.

Um dieses Problem zu lösen benutze ich zwei externe Bibliotheken:

CompileTesting bietet Funktionen, um das Auslesen/Parsen von Klassen zu simulieren.

JavaMirrorMocks ahmt u.a. das Verhalten des AP-eigenen Filers nach, d.h. damit lässt sich die Generierung der Dateien testen.

Um das Parsen zu testen, erstelle ich mir eine simple Dummyklasse, welche meine Annotation besitzt:

@GenerateInheriting(classNames = { "Dog", "Cat" })
public class AnimalTestClassWithTwoChildren {

}

Im Testfall benutze ich die CompileTesting-Bibliothek, um mir ein diese Klasse als TypeElement geben zu lassen. Dieses TypeElement ist fast identisch mit dem, welches mir mein Prozessor für diese Klasse erzeugen würde. Ich kann es also in meinen ElementParsingService hinein geben und überprüfen, ob er alle Informationen korrekt auswertet:

public class ElementParsingServiceTest {

    @Rule
    public CompilationRule rule = new CompilationRule();

    private Elements elements;

    @Before
    public void init() {
        elements = rule.getElements();
    }

    @Test
    public void testCreateAnnotatedClazz() throws Exception {
        TypeElement classElement = elements
                .getTypeElement("com.queomedia.generator.example.testclasses.AnimalTestClassWithTwoChildren");
        ElementParsingService classUnderTest = new ElementParsingService();

        AnnotatedClazz actualParsedClazz = classUn-derTest.createAnnotatedClazz(classElement);
        AnnotatedClazz expectedParsedClazz = new Annotat-edClazz("com.queomedia.generator.example.testclasses",
                "AnimalTestClassWithTwoChildren", new String[] { "Dog", "Cat" });

        assertEquals(expectedParsedClazz, actualParsedClazz);
    }
}

Hinweis: Die CompileTesting-Bibliothek selbst arbeitet zur Laufzeit, das heißt, dass alle Elemente, die nur zur Compilezeit verfügbar sind, von dieser Bibliothek nicht erkannt werden können.

An der Annotation GenerateInheriting kann man definieren, ob diese nur zur Compile- (RetentionPolicy.SOURCE) oder auch später zur Laufzeit (RetentionPolicy.RUNTIME / CLASS) verfügbar sein soll.

Mit RetentionPolicy.SOURCE ist die Annotation bereits verworfen / nicht mehr existent, wenn die Bibliothek wirksam wird. Annotiert man eine Klasse mit einer Annotation mit RetentionPolicy.SOURCE und versucht über die CompileTesting-Bibliothek auf die Annotation zuzugreifen, wird eine NullpointerException fliegen, da diese eben zur Laufzeit nicht mehr vorhanden ist.

Um den SourceFileWritingService zu testen, benötigt man ein Element, das sich möglichst exakt so verhält, wie der AP-Filer.

Deswegen lasse ich mir von JavaMirrorMocks einen Mock der ProcessingEnvironment erzeugen und von dieser den Filer geben:

public class SourceFileWritingServiceTest {

    @Test
    public void testCreateFiles() throws Exception {
        AnnotatedClazz annotatedClazz = new Annotat-edClazz("com.queomedia.generator.test", "Account",
                new String[] { "PrivateAccount" });

        MockProcessingEnvironment mockedEnvironment = new MockProcessingEnviron-ment("test");
        Filer filer = mockedEnvironment.getFiler();

        SourceFileWritingService classUnderTest = new SourceFileWritingService(filer);
        String actualFullqualifiedName = classUn-derTest.createFiles(Arrays.asList(annotatedClazz));

        String expectedFullqualifiedName = "com\\queomedia\\generator\\test\\PrivateAccount.java";
        assertEquals(expectedFullqualifiedName, actualFullqualifiedName);
    }
}

Codegenerierung On Demand

Es gibt Fälle, in denen keine permanente Generierung (d.h. bei jedem Build) gewünscht ist, sondern nur on demand.

In diesem Fall kann man sich in der pom.xml des jeweiligen Projektes (in meinem Fall das Example-Projekt) ein Maven-Profil definieren, welches den Prozessor als Dependency hat. Dies führt dazu, dass der Prozessor erst eingebunden (und damit aufgerufen) wird, wenn der Build mit dem entsprechenden Profil gestartet wird.

    <dependencies>
        <dependency>
            <groupId>com.queomedia.generator.example</groupId>
            <artifactId>annotation</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
    </dependencies>

    <profiles>
        <profile>
            <id>generateInheriting</id>
            <dependencies>
                <dependency>
                    <groupId>com.queomedia.generator.example</groupId>
                    <artifactId>annotation-processor</artifactId>
                    <version>0.0.1-SNAPSHOT</version>
                </dependency>
            </dependencies>
        </profile>
    </profiles>

Startet man jetzt den Build nur mit „mvn clean install“, so werden keine Dateien generiert, mit „mvn clean install -PgenerateInheriting“ hingegen wird der Prozessor mit durchlaufen und erzeugtt die gewünschten Dateien.

Die Einsatzmöglichkeiten von AP sind nahezu unbegrenzt und gerade in großen Projekten mit vielen ähnlichen Strukturen kann es sich lohnen, über eine solche Automatisierung nachzudenken.

Ich hoffe, dass ich mit diesem Artikel einen kleinen Einblick in die Codegenerierung unter Java geben konnte.

Beispielprojekt:
CodeGenerator (Download)

Wir suchen einen Java Developer (m/w)!
Du sprichst und schreibst fließend Java? Gewissenhaft und mit hohem Qualitätsanspruch arbeitest du an cleveren Lösungen und bist erst dann zufrieden, wenn auch wirklich alles zuverlässig funktioniert? Die Entwicklung automatisierter Tests ist für dich keineswegs eine lästige Pflicht, sondern lässt dein Herz höher schlagen? Dann nichts wie auf zu queo!

Diskutieren Sie mit!

Alle mit einem markierten Felder sind Pflichtfelder und müssen ausgefüllt werden