Generieren oder nicht generieren – das ist hier die Frage!

Manuell oder automatisch? Die Codegenerierung für Java im Fokus

Peter Verhas

© Shutterstock / oatawa

In diesem Artikel geht es um Codegenerierung, warum wir Code generieren müssen und wie es funktioniert. Zunächst möchte ich allgemein beschreiben, warum die Generierung von Code notwendig ist, und auch ein bisschen Theorie einfließen lassen (aber nicht zu viel). Außerdem geht es um die verschiedenen Phasen der Softwareentwicklung, in denen der Quellcode programmatisch generiert werden kann, und ich vergleiche die verschiedenen Ansätze. Nicht zuletzt gehe ich auf die Architektur und die Idee hinter einem spezifischen Tool ein, das in einer bestimmten Phase Code generiert.

Um mit den Worten Shakespeares zu sprechen: Code generieren oder nicht generieren, das ist hier die Frage. Und die Praxis gibt die Antwort, genau wie in Hamlet. Wir generieren Code. Viele Entwickler generieren Code. Auch wenn uns das nicht gefällt. Bei Tool-generiertem Code haben wir ein schlechtes Gefühl. Es fühlt sich unprofessionell oder zumindest nicht optimal an. Aber so ist das Leben. Das meiste ist nicht optimal, und trotzdem müssen wir damit umgehen. So auch bei der automatisierten Codegenerierung.

Warum wir Code generieren

Der Hauptgrund für die automatische Codegenerierung ist, dass uns manchmal nichts anderes übrigbleibt oder einfällt. Die manuelle Codegenerierung kann zu umständlich und fehleranfällig sein oder die Sprache, das Framework oder einfach unsere Erfahrung und unser Wissen erlauben keine simplere Lösung. Und das ist bereits die Quintessenz dieses Artikels, schon ganz am Anfang. Bevor wir uns für die Generierung von Code entscheiden, müssen wir wissen, warum wir ihn überhaupt brauchen.

Keinen Code generieren, außer Sie müssen

Verrückt, oder? Besonders, wenn ich über ein Open-Source-Tool spreche, das genau auf die Generierung von Java-Code ausgerichtet ist. Und trotzdem bin ich der Meinung, dass so viel Code wie möglich manuell geschrieben werden sollte. Leider oder – im Sinne des erwähnten Tools – zum Glück gibt es genug Gelegenheiten, bei denen eine manuelle Codegenerierung keine Option ist. Oder zumindest die automatisierte Codegenerierung die bessere Option zu sein scheint.

Warum soll man überhaupt Code generieren?

Wenn die beste Option die Generierung von Code zu sein scheint, dann läuft im System etwas falsch oder zumindest suboptimal (Abb. 1):

  • Der Entwickler, der den Code erstellt, ist unterdurchschnittlich.
  • Die Programmiersprache ist unterdurchschnittlich.
  • Die Umgebung oder/und ein Framework sind unterdurchschnittlich.
Abb. 1: Gründe für die Generierung von Code

Abb. 1: Gründe für die Generierung von Code

Sie sollten sich nun nicht angegriffen fühlen. Wenn ich über einen „unterdurchschnittlichen Entwickler“ spreche, meine ich doch nicht Sie. Als Entwickler liegen Sie weit über dem Durchschnitt, nicht zuletzt, weil Sie offen sind und sich für neue Dinge interessieren. Das ist schon dadurch bewiesen, dass Sie diesen Artikel lesen. Wenn Sie jedoch Code schreiben, sollten Sie auch durchschnittliche Hinz-und-Kunz-Entwickler berücksichtigen, die ihr Programm irgendwann in der Zukunft warten müssen. Und durchschnittliche Entwickler haben ein besonderes Merkmal: Sie sind nicht gut. Sie sind auch nicht schlecht, aber, wie der Name schon sagt, sie sind eben durchschnittlich.

Die Legende des unterdurchschnittlichen Entwicklers

Vielleicht passiert Ihnen genau das, was mir vor einigen Jahren passiert ist:
Für die Lösung eines Problems habe ich ein Mini-Framework erstellt. Nicht wirklich ein Framework, wie Spring oder Hibernate, denn ein einzelner Entwickler kann so etwas nicht entwickeln (das hält allerdings einige nicht davon ab, es trotzdem zu versuchen, sogar in einer professionellen Umgebung – was sich widerspricht, denn das ist unprofessionell). Man braucht ein Team. Was ich erstellt habe, war eine einzelne Klasse, die mit ein bisschen „Reflexionszauberei“ Objekte in Karten und wieder zurück konvertiert hat. Zuvor hatten wir die Methoden toMap() und fromMap() in allen Klassen, die diese Funktionalität benötigten. Sie wurden manuell erstellt und gewartet.

Zum Glück musste ich das nicht allein tun und hatte ein Team. Meine Kollegen sagten mir, ich solle doch den von mir geschriebenen Code in die Tonne treten und weiterhin die toMap() und fromMap() manuell erstellen. Der Grund dafür ist, dass der Code von den Entwicklern, die nach uns kommen, gewartet werden muss. Und diese kennen wir nicht, ja, es gibt sie ja noch nicht einmal. Sie studieren vielleicht noch. Vielleicht sind sie noch nicht einmal auf der Welt. Eines wissen wir aber: Sie werden durchschnittlich sein, und der von mir erstellte Code erfordert etwas mehr als durchschnittliche Fähigkeiten. Die Wartung der manuell erstellten Methoden toMap() und fromMap() erfordert jedoch lediglich eine durchschnittliche Kompetenz, auch wenn die Wartung fehleranfällig ist. Aber das ist eher ein Kostenproblem, das etwas mehr Investition in die Qualitätssicherung erfordert, aber deutlich weniger Kosten generiert, als die Einstellung von Senior Developer-Assen.

Sie können sich meine zwiespältigen Gefühle vorstellen, da mein brillanter (Achtung, Ironie!) Code zwar abgelehnt, aber dennoch mein Ego gestreichelt wurde. Und ich muss sagen, die Kollegen hatten Recht.

Unterdurchschnittliches Framework

Nun, viele Frameworks sind in diesem Sinne unterdurchschnittlich. Vielleicht ist dieser Ausdruck auch nicht wirklich der beste. Beispielsweise wird Java-Code aus einer WSDL-Datei generiert. Warum generiert das Framework Quellcode anstelle von Java-Bytecode? Dafür gibt es einen guten Grund.

Die Generierung von Bytecode ist komplex und erfordert spezielle Kenntnisse. Und das ist mit Kosten verbunden. Eine Byte-Code-Generierungsbibliothek wie Byte Buddy wird benötigt. Für den Programmierer ist es schwieriger, mit dem Code zu debuggen, und es hängt auch ein wenig von der JVM-Version ab. Falls der Code als Java-Quellcode generiert wird – und das gilt auch, wenn es sich um eine spätere Version von Java handelt und das Projekt eine ältere Version verwendet – stehen die Chancen, dass das Projekt den generierten Code irgendwie herabstufen kann, bei einem Java-Code besser als bei einem Bytecode.

Unterdurchschnittliche Sprache

Natürlich sprechen wir hier nicht von Java, denn Java ist die beste Sprache der Welt – es gibt keine bessere. Oder? Wenn jemand behauptet, dass eine Sprache perfekt sei, ignorieren Sie diesen jemand einfach. Jede Sprache hat ihre Stärken und Schwächen. Java ist da nicht anders. Wenn man bedenkt, dass diese Sprache vor über 20 Jahren entworfen wurde und die Rückwärtskompatibilität gemäß der Entwicklungsphilosophie sehr streng gehandhabt wurde, heißt das einfach, dass andere Sprachen in manchen Bereichen besser sein sollten.

Denken Sie an die Methoden equals() und hashCode(), die in der Klasse Object definiert sind und die in jeder Klasse überschrieben werden können. Es gibt nicht viele Neuerungen, was die Überschreibung betrifft. Diese überschriebenen Implementierungen sind eher Standard. Tatsächlich sind sie sogar so standardisiert, dass die integrierten Entwicklungsumgebungen die Generierung von Code für diese Methoden jeweils unterstützen. Warum sollten dann wir Code für die Methoden generieren? Warum sind sie nicht auf deklarative Art und Weise Teil der Sprache? Das sind alles Fragen, auf die es sehr gute Antworten geben sollte, denn es wäre wirklich nicht sehr aufwändig, Dinge wie diese in die Sprache zu implementieren, aber das passiert einfach nicht. Es hat seinen guten Grund, warum ich nicht der beste Kandidat bin, darüber zu schreiben.

Es gibt noch ein weiteres Beispiel: den Lambdaausdruck. Dieser wurde erst vor kurzem, etwa vor fünf Jahren, in die Sprache eingeführt. Zuvor mussten Programmierer zum Beispiel anonyme Klassen verwenden. Lassen wir die Tatsache beiseite, dass sich die Performance und die Bytecode-Implementierung einer anonymen Klasse nicht mit der eines Lambdaausdrucks vergleichen lassen. Der Lambda-Ausdruck konnte vor Version 8 mit Hilfe eines Codegenerators in die Sprache eingeführt werden, der die anonymen Klassen aus einer verkürzten Form heraus generiert hat. Einige Funktionen wie Lambda, mehrzeilige Zeichenketten und Schalterausdrücke waren oder sind aufgrund der Sprachentwicklung nicht Teil der Sprache. Eine Funktion, die sich als nicht wirklich gut erwiesen hat, kann nicht in eine solche Sprache wie Java implementiert werden. Es sollte Experimente in anderen Sprachen geben, denn die Branche bzw. die Entwickler müssen diese Funktion in anderen Sprachen akzeptieren. An diesem Punkt kann eine Implementierung in Java bedeuten, dass die Funktion Jahrzehnte, vielleicht Jahrhunderte Bestandteil von Java bleibt.

Zusammenfassend möchte ich sagen: Wenn man sich nicht auf manuell generierten Code verlassen kann, gilt er einfach als unterdurchschnittlich. Das ist auch in Ordnung so. So ist das in unserem Beruf eben: Es gibt keine Ideallösung, wir müssen mit Kompromissen leben.

Und jetzt befassen wir uns ein wenig mit der Theorie, die erklärt, warum das so ist.

Java Whitepaper

Gratis-Dossier: Java 2020 – State of the Art

GraalVM, Spring Boot 2.2, Kubernetes, Domain-driven Design & Machine Learning sind einige der Themen, die Sie in unserem brandneuen Dossier 2020 wiederfinden werden!

Redundanter Code

Die Grundursache bzw. der eigentliche Grund, warum wir Code generieren müssen, ist die Redundanz. Der unterdurchschnittliche Entwickler, die unterdurchschnittliche Sprache, das Framework oder was auch immer existiert der Redundanz wegen. In Wikipedia wird redundanter Code so definiert (in deutscher Übersetzung):

Redundanter Code ist in der Programmierung der Begriff für den Quellcode oder kompilierten Code in einem Computerprogramm, der überflüssig ist, wie beispielsweise …

Das ist genau der Teil der Redundanz, den ich ansprechen möchte. Tatsächlich ist dies der letzte Redundanztyp und falls diese Redundanz durch Codegenerierung beantwortet wird, ist dies das beste Beispiel, wann Code NICHT generiert werden soll. In diesem Artikel beziehe ich mich auf die informationstheoretische Bedeutung von Redundanz. Werfen wir wieder einen Blick auf die Wikipedia-Seite:

In der Informationstheorie ist die Redundanz die Bruchdifferenz zwischen der Entropie H(X) eines Ensembles X und dem maximal möglichen Werte-Log (|A_X|).

Die Definition ist zwar sehr genau, aber dafür auch sehr unbrauchbar. Zum Glück geht die Beschreibung auf der Seite so weiter:

Einfacher gesagt ist dies die Menge an verschwendetem ‚Platz‘, der für die Übertragung bestimmter Daten verwendet wird. Die Datenkompression ist eine Möglichkeit, unerwünschte Redundanzen zu reduzieren oder gar zu beseitigen.

Mit anderen Worten: Einige irgendwie codierte Informationen sind überflüssig, wenn sie komprimiert werden können. So wird beispielsweise der heruntergeladene und gezippte Text des Klassikers Moby Dick auf 40 Prozent der Größe des Originaltexts reduziert. Wenn man das Gleiche mit dem Quellcode von Apache Commons Lang macht, bekommen wir 20 Prozent. Es liegt definitiv nicht an diesem „Code in einem Computerprogramm, der überflüssig ist“. Das ist eine andere „notwendige“ Redundanz. Deutsch, englisch und andere Sprachen sind redundant, Programmiersprachen sind redundant und so ist es eben.

Redundanzstufen

Damit stellt sich bereits die nächste Frage, nämlich ob es noch weitere Gründe für Redundanz gibt. Die Antwort ist, dass wir sechs verschiedene Stufen der Redundanz identifizieren können, einschließlich der bereits erwähnten.

0. Natürlich: Hier handelt es sich um die Redundanz der deutschen, der englischen oder einfach jeder anderen natürlichen Sprache. Diese Redundanz ist natürlich und wir haben uns daran gewöhnt. Die Redundanz hat sich zusammen mit der Sprache entwickelt und war notwendig, um beispielsweise das Verstehen in einer lauten Umgebung zu erleichtern. Diese Redundanz wollen wir nicht beseitigen, denn ansonsten enden wir möglicherweise beim Binärcode. Für die meisten von uns wäre das nicht wirklich schön. So funktionieren das menschliche Hirn und das Hirn eines Programmierers.

1. Sprache: Auch die Programmiersprache ist redundant. Sogar noch redundanter als die natürliche Sprache, auf der sie aufbaut. Diese zusätzliche Redundanz ergibt sich daraus, dass die Anzahl der Keywords sehr begrenzt ist. Das macht im Falle von Java eine Komprimierung zwischen 60 und 80 Prozent aus. Andere Sprachen, wie Perl, sind dichter und deshalb leider schlechter lesbar. Aber auch das ist eine Redundanz, die wir beibehalten wollen. Eine Verringerung der Redundanz, die aus der Redundanz der Programmiersprache entsteht, würde sicherlich die Lesbarkeit und damit die Wartbarkeit verringern.

2. Struktur: Es gibt eine weitere Redundanzquelle, die bereits unabhängig von der Sprache ist, und zwar die Redundanz der Codestruktur. Nehmen wir zum Beispiel eine Methode mit einem Argument, dann sollten die Codefragmente, die diese Methode aufrufen, auch ein Argument verwenden. Wenn sich die Methode für mehr Argumente ändert, dann müssen auch alle Stellen, die diese Methode aufrufen, geändert werden. Das ist eine Redundanz, die sich aus der Programmstruktur ergibt, und das wollen wir nicht nur nicht vermeiden, sondern können es auch nicht vermeiden, denn sonst würden wir Informationen und damit die Codestruktur verlieren.

3. Domäneninduzierte Redundanz: Wir sprechen von domäneninduzierter Redundanz, wenn die Geschäftsdomäne klar und präzise beschrieben werden kann, die Programmiersprache eine solche Beschreibung aber nicht unterstützt. Ein gutes Beispiel dafür ist ein Compiler. Dieses Beispiel befindet sich in einer technischen Domäne, mit der die meisten Programmierer vertraut sind. Eine kontextfreie Syntaxgrammatik kann auf klare und ansprechende Weise im BNF-Format geschrieben werden. Erstellen wir den Parser in Java, wird er sicherlich länger sein. Da das BNF-Format und der Java-Code dasselbe bedeuten, der Java-Code aber wesentlich länger ist, können wir sicher sein, dass der Java-Code aus informationstheoretischer Sicht redundant ist. Aus diesem Grund haben wir für diese Beispieldomäne Tools wie zum Beispiel ANTLR, Yacc und Lex und einige andere.

Ein weiteres Beispiel ist das Fluent API. Ein Fluent API wird durch die Implementierung mehrerer Schnittstellen programmiert, die den Programmierer durch die möglichen Sequenzen von verketteten Methodenaufrufen führen. Die Programmierung eines Fluent API dauert lange und ist schwierig. Gleichzeitig kann eine Fluent-API-Grammatik mit einem regulären Ausdruck präzise beschrieben werden, da Fluent APIs mit einer Grammatik im finiten Status beschrieben werden. Der reguläre Ausdruck, der die Methoden auflistet, die die Alternativen, Sequenzen, optionalen Aufrufe und Wiederholungen beschreiben, ist besser lesbar, kürzer und auch weniger redundant als die Java-Implementierung desselben. Deshalb nutzen wir Tools wie Java::Geci-Fluent-API-Generatoren, die einen regulären Ausdruck von Methodenaufrufen in eine Fluent-API-Implementierung umwandeln.

Das ist ein Bereich, in dem eine Verringerung der Redundanz wünschenswert ist und zu einer einfacheren Wartung und besseren Lesbarkeit des Codes führen kann.

4. Sprachentwicklung: Die Redundanz der Sprachentwicklung ist der domäneninduzierten Redundanz ähnlich, aber unabhängig von der tatsächlichen Programmierdomäne. Die Quelle dieser Redundanz ist eine Schwäche in der Programmiersprache. Java stellt beispielsweise nicht automatisch Getter und Setter für Felder bereit. C# und Swift tun das schon. Wenn wir Getter und Setter in Java benötigen, müssen wir dafür Code schreiben. Hierbei handelt es sich um einen Standardcode und das ist eine Schwachstelle in der Sprache. Außerdem gibt es in Java keine deklarative Möglichkeit, die Methoden equals() und hashCode() zu definieren. Vielleicht wird dieses Problem in einer späteren Version von Java gelöst. Wenn man sich frühere Versionen von Java ansieht, so war die Erstellung einer anonymen Klasse sicherlich redundanter, als einen Lambda-Ausdruck zu schreiben. Java entwickelte sich weiter und letzterer wurde in die Sprache eingeführt.

Der Hauptunterschied zwischen domäneninduzierter Redundanz und sprachentwicklungsbedingter Redundanz besteht darin, dass es zwar nicht möglich ist, alle Programmierdomänen in einer universellen Programmiersprache abzudecken, die Sprachentwicklung aber sicherlich die durch sprachliche Mängel verstärkte Redundanz beseitigt. Während sich die Sprache weiterentwickelt, gibt es in den IDEs und in Programmen wie Lombok Codegeneratoren, die sich dieser Probleme annehmen.

5. Programmierer-induzierte Redundanzen: Diese Art der Redundanz korreliert mit der klassischen Bedeutung der Code-Redundanz. In diesem Fall ist der Programmierer nicht gut genug im Generieren von Code und es gibt unnötige und exzessive Code-Strukturen oder sogar Copy-and-paste-Code im Programm. Ein typisches Beispiel ist die bereits erzählte „Legende des unterdurchschnittlichen Entwicklers“. In diesem Fall mag die Codegenerierung ein Kompromiss sein, aber in der Regel ist sie eine schlechte Wahl. Auf einer hohen Ebene und aus Sicht des Projektleiters mag das in Ordnung sein, denn der Projektleiter hat die Entwicklerkosten im Blick und könnte sich dafür entscheiden, kostengünstigere Entwickler einzustellen. Auf Programmiererebene hingegen ist das nicht akzeptabel. Wenn man die Wahl zwischen der Generierung von Code oder dem Schreiben von besserem Code hat, muss man immer letzteres wählen. Außerdem muss man sich weiterbilden und sich weiterentwickeln, um besseren Code schreiben zu können.

Wann soll Code generiert werden?

Nachdem wir über die verschiedenen Stufen oder Ursachen der Code-Redundanz gesprochen haben, kommen wir zur nächsten Frage: Wie wird Code generiert? Im Falle der Sprachentwicklung ist die Antwort einfach. Es gibt Tools, die wir nutzen. Wenn wir sie nutzen, generieren sie Code, wann immer wir das möchten. Zur Eliminierung oder Reduzierung von domäneninduzierter Redundanz haben wir das Java::Geci-Framework entwickelt, mit dem Programmierer ihre eigenen Codegeneratoren speziell für die Programmierdomäne schreiben können. Die Struktur und die Entscheidung, wo die Codegenerierungsphase einzubinden ist, war von dem Wunsch eines einfachen und netten API beseelt, mit dem es extrem einfach ist, einen Codegenerator zu erstellen. Wir werden uns nun also die verschiedenen Phasen des Entwicklungslebenszyklus ansehen, in denen die Codegenerierung stattfinden kann, um anschließend darüber zu sprechen, warum Java::Geci Code in einer bestimmte Phase generiert. Die Codegenerierung kann grundsätzlich in diesen Phasen erfolgen (Abb. 2):

  • (BC) Vor der Kompilierung
  • (DC) Während der Kompilierung
  • (DT) Während der Testphase
  • (DCL) Während des Ladens von Klassen
  • (DRT) Während der Laufzeit

Im Folgenden sehen wir uns diese Fälle näher an.

Abb. 2: Wann Codegenerierung erfolgen kann

Abb. 2: Wann Codegenerierung erfolgen kann

(BC) Vor der Kompilierung: Die herkömmliche oder konventionelle Phase ist vor der Kompilierung angesiedelt. In diesem Fall liest der Codegenerator eine Konfiguration oder vielleicht auch den Quellcode und generiert Java-Code in der Regel in einem bestimmten Verzeichnis, das vom manuell erstellten Quellcode getrennt ist. Hier ist der generierte Quellcode nicht Teil des Codes, der in das Versionskontrollsystem gelangt. Die Codewartung muss sich um die Codegenerierung kümmern und es ist kaum eine Option, den Codegenerator aus dem Prozess auszulassen und den Code weiter manuell zu warten.

Der Zugriff auf die Struktur des Java-Codes ist für den Codegenerator nicht einfach. Wenn der generierte Code den bereits vorhandenen manuellen Code irgendwie verwenden, erweitern oder ergänzen muss, dann muss er dafür den Java-Quellcode analysieren. Das kann Zeile für Zeile oder mit einem Parser erfolgen. In beiden Fällen ist das eine Aufgabe, die später vom Java-Compiler erneut ausgeführt werden wird. Es besteht dabei die geringe Wahrscheinlichkeit, dass der Java-Compiler und das Tool zum Parsen des Codes für den Codegenerator nicht zu 100 Prozent kompatibel sind.

(DC) Während der Kompilierung: Mit Java können sogenannte Annotationsprozessoren erstellt werden, die vom Compiler aufgerufen werden. Diese können während der Kompilierungsphase Code generieren. Der Compiler kompiliert dann die generierten Klassen. Auf diese Weise ist die Codegenerierung Teil der Kompilierungsphase.

Die in dieser Phase ausgeführten Codegeneratoren können nicht auf den kompilierten Code, aber über ein API auf die kompilierte Struktur zugreifen, die der Java-Compiler für die Annotationsprozessoren bereitstellt. Es ist zwar möglich, neue Klassen zu generieren, aber der vorhandene Quellcode kann nicht geändert werden.

(DT) Während der Testphase: Zunächst erscheint das ein bisschen verrückt. Warum sollte jemand während der Testphase Code generieren wollen? Das Open-Source-Tool, das ich hier „zu verkaufen“ versuche, tut genau das, und ich werde die Einsatzmöglichkeiten sowie die Vor- und ehrlicherweise auch die Nachteile der Codegenerierung in dieser Phase noch genau beleuchten.

(DCL) Während des Ladens von Klassen: Man kann Code auch beim Laden von Klassen generieren. Die Programme, die dies können, heißen Java-Agenten. Dabei handelt es sich nicht um echte Codegeneratoren. Sie arbeiten auf Bytecode-Ebene und modifizieren den bereits kompilierten Code.

(DRT) Während der Laufzeit: Einige Codegeneratoren arbeiten während der Laufzeit. Viele dieser Anwendungen generieren Java-Bytecode direkt und laden den Code in die laufende Anwendung. Es ist auch möglich, Java-Quellcode zu generieren, den Code zu kompilieren und die resultierenden Bytes in die JVM zu laden.

Code in der Testphase generieren

In dieser Phase generiert Java::Geci (Java GEnerate Code Inline) den Code. Damit Sie verstehen, wie man auf die verrückte Idee kommt, Code während des Unit-Tests (wenn es bereits zu spät und Code bereits kompiliert ist) zu generieren, ist folgende Geschichte möglicherweise hilfreich. Sie ist zwar frei erfunden und nie passiert, das schmälert jedoch nicht die Aussagekraft:

Wir hatten einen Code mit mehreren Datenklassen und jeweils mehreren Feldern. Für jede dieser Klassen mussten wir die Methoden equals() und hashCode() erstellen. Das führte schließlich zur Redundanz des Codes. Wurde eine Klasse geändert, ein Feld hinzugefügt oder gelöscht, dann mussten auch die Methoden geändert werden. Ein Feld zu löschen, war kein Problem: Der Compiler kompiliert keine equal()– oder hashCode()-Methode, die sich auf ein nichtexistierendes Feld bezieht. Andererseits stört den Compiler eine solche Methode – die nicht auf ein neues bestehendes Feld verweist – auch nicht.

Ab und zu haben wir vergessen, diese Methoden zu aktualisieren, und versucht, dem fehleranfälligen menschlichen Coding mit komplexeren und besseren Wegen entgegenzuwirken. Die verrückteste unserer Ideen war, einen MD5-Wert der Feldnamen zu erstellen und diesen als Kommentar in die Methoden equals() und hashCode() einzufügen. Im Falle einer Änderung in den Feldern könnte ein Test überprüfen, ob der Wert im Quellcode von dem Wert abweicht, der sich aus den Namen der Felder ergibt, und dann den Fehler unit test fails melden. Wir haben die Idee nie umgesetzt.

Wir hatten aber eine noch verrücktere Idee, die sich letztlich als gar nicht so verrückt herausstellte und schließlich in Java::Geci gipfelte. Die Idee war, den erwarteten equals()– und hashCode()-Methodentest während des Tests der per Reflexion verfügbaren Feldern zu erstellen und mit demjenigen zu vergleichen, der bereits im Code enthalten war. Wenn sie nicht übereinstimmen, müssen sie neu generiert werden. Der Code an dieser Stelle ist jedoch bereits neu generiert. Das einzige Problem dabei ist, dass er sich im Speicher der JVM befindet und nicht in der Datei mit dem Quellcode. Warum wird nur ein Fehler gemeldet und dem Programmierer gesagt, dass der Code neu generiert werden soll? Warum schreibt der Test die Änderung nicht zurück? Schließlich sollten wir, die Menschen, dem Computer sagen, was er tun soll und nicht umgekehrt! Und das war letztendlich die Offenbarung, die zu Java::Geci geführt hat.

Java::Geci-Architektur

Java::Geci generiert Code in der Mitte des Kompilierungs-, Bereitstellungs- und Ausführungslebenszyklus. Java::Geci wird gestartet, wenn die Unit-Tests während der Build-Phase laufen. Tatsächlich müssen Sie einen oder mehrere Unit-Tests schreiben, um die Codegenerierung konfigurieren und starten zu können. Das heißt, dass der manuelle und zuvor generierte Code bereits kompiliert ist und dem Codegenerator per Reflexion zur Verfügung steht.

Code während der Testphase zu generieren, hat einen weiteren Vorteil. Jede spätere Codegenerierung soll nur Code erzeugen, der orthogonal zur manuellen Codefunktionalität ist. Und was heißt das jetzt genau? Er muss orthogonal sein, d. h. der generierte Code darf den vorhandenen, durch die Unit-Tests entdeckten und manuell erstellten Code weder verändern noch in irgendeiner Weise stören. Der Grund dafür ist, dass eine in einer späteren Phase stattfindende Codegenerierung erst nach der Ausführung des Unit-Tests erfolgt und somit keine Möglichkeit mehr besteht, zu testen, ob der generierte Code das Verhalten des Codes auf unerwünschte Weise beeinflusst.

Die Codegenerierung während des Tests bietet die Möglichkeit, den gesamten Code zu testen, also sowohl den manuellen als auch den generierten. Der generierte Code selbst sollte nicht per se getestet werden – das ist Aufgabe des Tests des Codegeneratorprojekts –, aber das Verhalten des von den Programmierern geschriebenen manuellen Codes, und damit auch die Ausführung der Tests, können vom generierten Code abhängen.

Damit sichergestellt ist, dass alle Tests mit dem generierten Code korrekt sind, sollten die Kompilierung und die Tests noch einmal ausgeführt werden, falls neuer Code generiert wurde. Damit auch dies sichergestellt ist, wird die Codegenerierung aus einem Test aufgerufen und der Test schlägt fehl, falls neuer Code generiert wurde. Um dies zu beheben, wird die Codegenerierung in Java::Geci normalerweise aus einem dreizeiligen Komponententest mit folgender Struktur aufgerufen:

Assertions.assertFalse(...generate(...),"code has changed, recompile!");

Der Aufruf von …generate(…) ist eine Kette von Methodenaufrufen, die das Framework und die Generatoren konfigurieren. Beim Ausführen entscheidet dann das Framework, ob sich der generierte Code vom bereits vorhandenen Code unterscheidet oder nicht. Es schreibt Java-Code zurück in den Quellcode, wenn dieser sich geändert hat, lässt ihn aber intakt, falls sich der generierte Code nicht geändert hat.

Die Methode generate() ist der letzte Aufruf in der Kette zur Codegenerierung und gibt „true“ zurück, wenn ein Code geändert und in den Quellcode zurückgeschrieben wurde. Damit schlägt der Test zwar fehl, wenn wir ihn jedoch erneut mit den bereits modifizierten Quellen durchführen, sollte er gut laufen. Diese Struktur hält für die Generatoren einige Einschränkungen bereit:

  • Generatoren sollten genau den gleichen Code generieren, wenn sie auf der gleichen Quelle und den gleichen Klassen ausgeführt werden. Diese Anforderung ist in der Regel nicht zwingend, denn Codegeneratoren neigen nicht dazu, zufällige Quellen zu generieren. Einige Codegeneratoren möchten möglicherweise Zeitstempel als Kommentar in den Code einfügen. Codeformatierung und Kommentaränderungen werden standardmäßig ignoriert (konfigurierbar).
  • Der generierte Code wird Teil der Quelle und ist kein Compile-Zeitartefakt. Das ist normalerweise bei allen Codegeneratoren der Fall, die Code in bereits vorhandene Klassenquellen generieren. Java::Geci kann separate Dateien erzeugen, wurde aber hauptsächlich für die Generierung von Inline-Code (daher der Name) entwickelt.
  • Der generierte Code muss im Repository gespeichert werden und die manuelle Quelle sich zusammen mit dem generierten Code in einem Zustand befinden, der keine weitere Codegenerierung erfordert. Damit ist sichergestellt, dass der CI-Server in der Entwicklung mit dem ursprünglichen Workflow arbeiten kann: Artefakte abrufen, kompilieren, testen und in das Repository übertragen. Die Codegenerierung wurde bereits auf dem Entwicklercomputer durchgeführt und der Codegenerator auf dem CI stellt nur sicher, dass diese auch wirklich durchgeführt wurde (ansonsten schlägt der Test fehl).

Zu beachten ist, dass die Tatsache, dass Code auf einem Entwicklercomputer generiert wird, nicht gegen die Regel verstößt, dass der Build maschinenunabhängig sein sollte. Bei einer Maschinenabhängigkeit würde die Codegenerierung zu unterschiedlichem Code auf dem CI-Server führen und somit den Build abbrechen. Genau das ist bei einigen Beispielgeneratoren früherer Versionen passiert. Der Fehler liegt beim Generator selbst.

Codegenerierungs-API

Die Anwendungen eines Codegenerators sollten einfach sein. Das Framework muss alle Aufgaben erfüllen, die auch die meisten Codegeneratoren erfüllen müssen und sollte unterstützend wirken. Was sonst ist schließlich die Aufgabe eines Frameworks? Java::Geci erledigt viele Dinge für die Codegeneratoren:

  • Es kümmert sich um die Konfiguration der Dateigruppen, um die Quelldateien zu finden.
  • Es scannt die Quellverzeichnisse und findet die Quellcodedateien.
  • Es liest die Dateien und hilft ihnen, wenn es sich um Java-Quellen handelt, dabei, die Klasse zu finden, die dem Quellcode entspricht.
  • Es unterstützt Reflexionsaufrufe, um die Generierung von deterministischem Code zu unterstützen.
  • Es sorgt für eine einheitliche Handhabung der Konfiguration.
  • Es generiert Java-Quellcode auf verschiedene Weise.
  • Es modifiziert die Quelldateien nur bei Änderungen und schreibt Änderungen zurück.
  • Es bietet voll funktionsfähige Beispielcodegeneratoren. Einer davon ist ein vollwertiger Fluent-API-Generator, der allein schon ein ganzes Projekt darstellen kann.
  • Es unterstützt Jamal-Vorlagen und die Generierung von Code.

Zusammenfassung

Beim Lesen dieses Artikels haben Sie ein Bild davon bekommen, warum, wie und wann wir in der professionellen Java-Entwicklung Code generieren. Auch habe ich Java::Geci kurz umrissen – ein Framework zur Erstellung domänenspezifischer Generatoren. Sie können eigentlich sofort damit starten. Besuchen Sie dafür einfach die GitHub-Homepage von Java::Geci.

Verwandte Themen:

Geschrieben von
Peter Verhas
Peter Verhas
Peter Verhas ist Senior Software Architect bei EPAM Schweiz. Er hat mehr als zehn Jahre Erfahrung in der Java-Entwicklung und mehr als zwanzig Jahre Erfahrung mit C und anderen Programmiersprachen. Zudem ist er Autor der Bücher „Java Projects“, „Mastering Java 9“ und „Java 9 Programming By Example“. Er bloggt außerdem regelmäßig in englischer Sprache (bei DZONE, Java Code Geeks und seinem eigenen Blog Javax0.wordpress.com). Peter hat einen Master in Elektrotechnik und studierte an der TU Budapest, der TU Wien und der TU Delft. Er arbeitete für Unternehmen wie Digital Equipment Corporation, T-Mobile und unterstützte die Telekommunikations- und Finanzbranche. Er war kurzzeitig Lehrer an der TU Budapest. Peter veröffentlicht auch Open-Source-Programme auf GitHub und ist Autor des ScriptBasic-Interpreters. GitHub: www.github.com/verhas
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: