Nehmen wir an, Du arbeitest an einem älteren Java-Projekt. Dein Team hat beschlossen, Lombok einzuführen, um alle Getter- und Setter-Methoden im Code zu entfernen und ihn dadurch lesbarer zu machen. Anstatt die Lombok-Annotationen manuell einzuführen und die Getter-/Setter-Methoden zu entfernen, möchtest Du die Aufagen mit OpenRewrite automatisieren. Da keine offiziell unterstützten Lombok-Rezepte verfügbar sind, musst Du eigene Rezepte schreiben.
Die Planung des OpenRewrite-Rezepts
Bevor Du das Rezept schreibst, solltest Du Dir zunächst genau überlegen, welche Vorgaben Dein Rezept erfüllen soll. Sonst läufst Du Gefahr, zu viel Code zu ändern oder bestimmte Anwendungsfälle zu vergessen. Der Einfachheit halber wollen wir uns auf das Ersetzen von Getter-Methoden durch die Lombok @Getter-Annotation konzentrieren, da das Ersetzen von Setter-Methoden fast identisch funktioniert. Für die Getter-Methoden lauten die Vorgaben wie folgt:
- Die
@Getter
-Annotation sollte nur dann zu einer Variablen hinzugefügt werden, wenn diese Variable ein Feld ist und eine entsprechende Getter-Methode in derselben Klasse hat. - Wenn ein Feld
f
einen boolean Typ hat, ist der Name der Getter-Methode gleichisF()
, andernfalls ist er gleichgetF()
. - Der Rückgabetyp der Getter-Methode muss mit dem Typ des Feldes übereinstimmen.
- Bei mehreren Felddeklarationen in einer einzigen Zeile darf die Annotation nur
hinzugefügt werden, wenn die Klasse für jedes Feld in dieser Deklaration eine Getter-Methode enthält.
Wenn die Klasse zum Beispiel die Deklaration
int a, b
hat, muss sie auch die Getter-MethodengetA()
undgetB()
besitzen.
Um die Erstellung von Rezepten mit zu viel komplexer Logik zu vermeiden, ist es hilfreich, das Rezept in mehrere Bausteine aufzuteilen. Jeder Baustein sollte ein eigenständiges Rezept sein und kann zu einem größeren Rezept zusammengefügt werden. Der Vorteil dieses Ansatzes ist, dass Du viele kleine Rezepte hast, die leicht zu testen sind, und dass Du oft bestehende Rezepte verwenden kannst, um kleine Aufgaben auszuführen. In unserem Fall wäre es hilfreich, das Rezept in die folgenden drei Teile zu unterteilen:
- Das Einfügen der Maven-Abhängigkeit für Lombok in die
pom.xml
des Projekts. - Das Platzieren jeder Variablendeklaration in einer eigenen Anweisung und in einer eigenen Zeile. Das verhindert die zusätzlichen Komplikationen mit mehreren Variablendeklarationen in einer einzigen Zeile.
- Das Hinzufügen der Lombok
@Getter
-Annotation zu allen Feldern, die eine entsprechende Getter-Methode haben. Im Anschluss müssen diese Getter-Methoden zusätzlich aus der Klasse entfernt werden.
Für die ersten beiden Schritte kannst Du bereits existierende Rezepte von OpenRewrite verwenden.
Mit dem AddDependency
-Rezept kann die Lombok-Abhängigkeit zum Projekt hinzugefügt werden,
falls sie noch nicht vorhanden ist. Außerdem kann das MultipleVariableDeclarations
-Rezept
die Deklaration mehrerer Variablen vereinfachen. Das letzte Rezept muss von Dir selbst implementiert werden.
Wie im vorherigen Beitrag beschrieben, kannst Du den deklarativen Ansatz verwenden,
um diese Rezepte zu einem einzigen Rezept zu kombinieren. Die Datei rewrite.yml
für das
vollständige Rezept sieht dann wie folgt aus:
Das Java-Projekt erstellen
Um dieses OpenRewrite-Rezept zu erstellen, musst Du zunächst ein eigenes Java-Projekt anlegen. Rezepte müssen in eigenen Projekten implementiert werden, damit sie als Abhängigkeit im OpenRewrite Maven/Gradle Plugin referenziert werden können.
Da wir den Java-Code modifizieren und auch die Maven pom.xml
unserer ursprünglichen
Code-Basis aktualisieren wollen, sollte der Abschnitt dependencies
in unserer Build-Datei
(in diesem Fall build.gradle.kts
) des Rezept-Projekts folgendermaßen aussehen:
Einen Akzeptanztest für das Rezept definieren
Bevor wir anfangen, ein Rezept zu schreiben, ist es sehr hilfreich, einen oder mehrere Akzeptanztests zu definieren, um sicherzustellen, dass das Rezept das tut, was es tun soll. Glücklicherweise bietet OpenRewrite ein Test-Framework, mit dem Rezepte einfach getestet werden können. Alles, was getan werden muss, ist einen Quellcodeausschnitt und den erwarteten Codeausschnitt zu übergeben, und die Bibliothek erledigt den Rest.
Stell Dir nun eine Klassendefinition vor, die alle Beschränkungen abdeckt, die vom Rezept berücksichtigt werden müssen. Ein gutes Beispiel für eine solche Klasse wäre:
Nach der Ausführung des Rezepts sollte die Klasse wie folgt geändert werden:
Tests für OpenRewrite-Rezepte müssen die Schnittstelle org.openrewrite.test.RewriteTest
implementieren.
Das Rezept, das getestet werden soll, kann durch Überschreiben der Methode defaults(RecipeSpec spec)
angegeben werden. Mit dem Parameter spec
kann entweder eine rewrite.yml
-Datei angegeben oder
die Java-Klasse für das Rezept instanziiert werden. Innerhalb der Testmethode wird OpenRewrite ausgeführt,
indem die Methode rewriteRun
aufgerufen wird. Diese Methode akzeptiert unterschiedliche Implementierungen
von SourceSpec
, je nach Art des zu testenden Rezepts. In diesem Fall möchten wir Java-Code umschreiben,
also werden wir die Methode java(..)
verwenden. Der Akzeptanztest für unser Rezept sieht so aus:
Das Schreiben des Rezepts zum Hinzufügen der Lombok @Getter Annotation
OpenRewrite-Rezepte werden als Java-Klassen definiert, die von der abstrakten
Basisklasse org.openrewrite.Recipe
erben. Fangen wir zunächst mit dem Teil
des Rezepts an, der eine @Getter
-Annotation zu Feldern hinzuzufügt, die eine
entsprechende Getter-Methode besitzen.
Der Kern der Rezeptlogik liegt in der Implementierung der abstrakten Basisklasse TreeVisitor
,
die in den meisten Fällen eine Implementierung der abstrakten JavaIsoVisitor
-Klasse ist (siehe die
Dokumentation für weitere Details zu diesem Thema). Der TreeVisitor hat Methoden
für alle möglichen Visitors, die für die LST der Quellcodedatei definiert werden können. Bei der Implementierung der Klasse ist es daher wichtig zu wissen, auf welche LST-Elemente der
Visitor angewendet werden soll. In unserem Fall möchten wir die Lombok @Getter
-Annotation
zu einem Feld einer Klasse hinzufügen. Daher macht es Sinn, die Visitormethode für
Variablendeklarationen zu implementieren. Die Struktur dieser Implementierung kannst wie
folgt schreiben:
Nun schauen wir uns an, wie wir die einzelnen Methoden implementieren können. Zunächst
müssen wir herausfinden, wie wir erkennen können, ob eine Variablendeklaration tatsächlich
eine Felddeklaration innerhalb einer Klasse ist. Ein wesentliches Merkmal eines Feldes ist,
dass es in einer Klasse deklariert wird und nicht innerhalb einer Methode.
Daher können wir ein Feld im LST erkennen, wenn das übergeordnete Knotenelement einer
Variablendeklaration eine Klassendeklaration ist. Dies kann mithilfe der Methode
getCursor()
erfolgen. Der Cursor zeigt auf das aktuell besuchte Element im LST
und bietet Methoden an, um zu anderen Elementen im LST zu navigieren.
Um herauszufinden, ob die Variablendeklaration bereits mit @Getter
annotiert ist,
können wir direkt eine der verfügbaren Methoden von J.VariableDeclarations
verwenden:
Um die Getter-Methoden für alle Variablen in der Variablendeklaration zu finden,
sollten wir nicht unseren aktuellen Visitor verwenden - wir müssten den Cursor
manuell durch den LST bewegen und können die vordefinierten Visitor-Methoden in
TreeVisitor
nicht nutzen. Daher ist es am besten, einen zweiten Visitor zu verwenden,
um die gewünschten Methoden zu finden. Glücklicherweise stellt OpenRewrite viele “Such-Visitor”
zum Finden bestimmter Elemente in einer LST zur Verfügung. In unserem Fall können wir den
FindMethods
-Visitor nutzen. Dieser Visitor nimmt ein Methodenmuster als Suchparameter
und liefert ein Set
aller übereinstimmenden Methodendeklarationen in der bereitgestellten
LST zurück (die in unserem Fall die LST der umschließenden Klasse sein sollte).
Das Methodenmuster ist als AspectJ-ähnlicher Ausdruck definiert (siehe die
Dokumentation für weitere Details).
Zum Beispiel, wenn der Name der Variable gleich x
ist, hat der Methodenausdruck die Form * isX()
,
wenn es sich um einen Boolean
handelt. In allen anderen Fällen hat es die Form * getX()
.
Zur Vereinfachung ignorieren wir hier den Rückgabetyp.
Schließlich sind wir an dem Punkt angekommen, an dem wir den Code schreiben können,
um die @Getter
-Annotation zur Variablendeklaration hinzuzufügen. Um neuen Code
für die aktuelle LST zu generieren, ist es nicht ratsam, die LST-Elemente von Hand
zu schreiben und sie den entsprechenden Objekten hinzuzufügen. Stattdessen wird
die JavaTemplate
-Klasse verwendet, um die LST-Elemente zu generieren, indem
sie einen Code-Schnipsel analysiert. Um den Code-Schnipsel korrekt zu analysieren,
sollte die JavaTemplate
-Instanz die korrekten Implementierungen aller in ihrer Definition
verwendeten Klassen kennen.
Standardmäßig kennt JavaTemplate
nur die Klassen, die von der Java-Laufzeitumgebung
bereitgestellt werden. Um neue Klassen zu dem Scope hinzuzufügen, kann
die javaParser(...)
-Methode verwendet werden, indem
etwa der Klassenpfad angegeben oder eine Stub-Implementierung bereitgestellt wird.
Stubs werden benötigt, wenn die Klassen auf dem Laufzeit-Klassenpfad nicht verfügbar sind.
Dies ist häufig der Fall, wenn Du Rezepte für Framework-Migrationen schreibst, bei denen nur
die ältere Framework-Version auf dem Klassenpfad liegt. Beim Definieren von Stubs braucht
man nur die absolute Mindestmenge an Deklarationen anzugeben, damit der JavaParser
funktioniert – nur genug, um die erforderlichen LST-Elemente zu bestimmen.
In unserem Fall sollte die JavaTemplate
-Instanz einen Stub für die Lombok-Annotation verwenden,
da wir Lombok nicht auf unserem Klassenpfad haben. Die Methode addGetterAnnotation
, die die Annotation zur Variablendeklaration hinzufügt, sieht dann so aus:
Entfernen der relevanten Getter-Methoden aus der Klasse
Als letzter Schritt in unserem Lombok-Refactoring-Rezept erweitern wir unseren Visitor,
um alle Getter-Methoden für Felder zu entfernen, die eine @Getter
-Annotation
aufweisen. Der perfekte Kandidat für diese Callback-Methode ist die
visitClassDeclaration(...)
-Methode. Die Klasse ClassDeclaration
kennt alle in der
Klasse deklarierten Anweisungen, wie Variablen- und Methodendeklarationen.
Die Schritte, die wir machen müssen, um die relevanten Getter-Methoden zu entfernen, sind Folgende:
- Zunächst müssen wir alle Felder finden, die eine
@Getter
-Annotation aufweisen. - Für jedes dieser Felder muss die entsprechende Getter-Methode gefunden werden.
- Schließlich müssen wir die
ClassDeclaration
so ändern, dass diese Getter-Methoden nicht mehr in der Liste der Anweisungen enthalten sind.
Der Code für die visitClassDeclaration(...)
-Methode sieht so aus:
Warum funktioniert das überhaupt? Auf den ersten Blick ist es nicht offensichtlich,
warum der Code in visitVariableDeclaration(...)
vor dem Code in visitClassDeclaration(...)
ausgeführt wird. Man könnte also denken,
dass hier nicht viel passieren sollte, da die Lombok-Annotation nach dem Besuch
der Klassendeklaration hinzugefügt werden könnte.
Der Schlüsselpunkt hier ist der Aufruf von super.visitClassDeclaration(...)
am Anfang
der Methode. Dieser führt dazu, dass der Visitor den Unterbaum durchläuft,
der in der Klassendeklaration enthalten ist, und den potenziell modifizierten Unterbaum zurückgibt.
Da Variablendeklarationen im Unterbaum der Klassendeklaration enthalten sind, ist das Ergebnis dieses Methodenaufrufs ein modifizierter LST mit Lombok-Annotationen auf seinen Feldern. Dadurch
können wir dann die Klassendeklaration auf die Art und Weise bearbeiten, die wir brauchen.
Letzte Anmerkungen
In diesem Beitrag haben wir uns auf die grundlegenden Mechanismen des Schreibens von Rezepten beschränkt. Es gibt aber noch viele weitere (fortgeschrittene) Konzepte in OpenRewrite, die Du erkunden kannst. Zum Beispiel:
- Das Schreiben von Rezepten über mehrere Quelldateitypen, z.B. zum Auswerten von Bedingungen in einer Properties-Datei, bevor ein Stück Java-Code modifiziert wird.
- Das Schreiben von nicht-isomorphen Java-Visitors, zum Beispiel, wenn die Visitor-Methode für eine Klassendeklaration einen anderen Typ von LST-Element zurückgeben sollte.
- Das Teilen von Daten zwischen Visitor-Methoden durch Verwendung von Markern oder Cursor-Nachrichten.
- Das Definieren eines Stils für Deinen Quellcode, sodass die OpenRewrite-Rezepte ihre Ergebnisse gemäß den Regeln in der Stildefinition formatieren.
- Und vieles mehr …
Weitere Details zu diesen Themen findest Du in der Dokumentation oder im Quellcode von OpenRewrite auf Github.
Der Code für das in diesem Beitrag erstellte Refactoring-Rezept kann hier auf meinem Github-Account gefunden werden.