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:

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:

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:

type: specs.openrewrite.org/v1beta/recipe
name: com.yourorg.openrewrite.lombok.IntroduceGetter
recipeList:
  - org.openrewrite.maven.AddDependency:
      groupId: org.projectlombok
      artifactId: lombok
      version: 1.18.24
  - org.openrewrite.java.cleanup.MultipleVariableDeclarations
  - com.yourorg.openrewrite.lombok.AddGetterAnnotations
rewrite.yml

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:

dependencies {
    // import Rewrite's bill of materials.
    implementation(platform("org.openrewrite.recipe:rewrite-recipe-bom:1.14.0"))

    // rewrite-java Abhängigkeiten für die Entwicklung von Java-Rezepten
    // Um mehrere Java-Versionen zu unterstützen, müssen die rewrite-java-8,
    // rewrite-java-11 and rewrite-java-17 Abhängigkeiten verwendet werden
    implementation("org.openrewrite:rewrite-java")
    runtimeOnly("org.openrewrite:rewrite-java-8") 
    runtimeOnly("org.openrewrite:rewrite-java-11")
    runtimeOnly("org.openrewrite:rewrite-java-17")
    testImplementation("org.openrewrite:rewrite-test")

    // Rezepte um die Maven pom.xml zu ändern
    implementation("org.openrewrite:rewrite-maven")

    // JUnit Jupiter für das Testen
    testImplementation("org.junit.jupiter:junit-jupiter:5.9.1")
}
build.gradle.kts

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:

package com.yourorg;

public class Customer {

    private boolean deleted;
    private int a, b=2;

    private String noGetter;

    public boolean isDeleted() {
        return this.name;
    }

    public int getA() {
        return this.a;
    }

    public int getB() {
        return this.b;
    }

    public String getSomeData() {
        return "some data";
    }
}
Test-Klasse für den Akzeptanztest

Nach der Ausführung des Rezepts sollte die Klasse wie folgt geändert werden:

package com.yourorg;

import lombok.Getter;

public class Customer {

    @Getter
    private boolean deleted;
    @Getter
    private int a;
    @Getter
    private int b = 2;

    private String noGetter;

    public String getSomeData() {
        return "some data";
    }
}
Erwartetes Ergebnis für den Akzeptanztest

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:

class LombokIntroduceGetterRewriteTest implements RewriteTest {

    @Override
    public void defaults(RecipeSpec spec) {
        spec.recipe("rewrite.yml");
    }

    @Test
    void testRecipe() {
        String before = "..."; // Der Quellcode für die Klassendefinition
        String after = "..."; // Der Quellcode für das erwartete Ergebnis
        rewriteRun(java(before, after));
    }
}
Akzeptanztest für das Rezept

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.

import org.openrewrite.Recipe;

public class AddGetterAnnotations extends Recipe {

    @Override
    public String getDisplayName() {
        return "Lombok - Add getter annotation to fields";
    }

    @Override
    public String getDescription() {
        return "Adds the Lombok @Getter annotation to fields, " + 
          "if they have a corresponding getter method.";
    }

    protected TreeVisitor<?, ExecutionContext> getVisitor() {
        return new JavaIsoVisitor<>() {
            // Implementierung der Visitor für das Rezept.
        }
    }
}
Struktur eines OpenRewrite Rezepts

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:

@Override
public J.VariableDeclarations visitVariableDeclarations(
        J.VariableDeclarations multiVariable, ExecutionContext executionContext) {

    J.VariableDeclarations v = super.visitVariableDeclarations(multiVariable, 
        executionContext);

    // Mach nichts, wenn die Variablendeklaration kein Feld is oder wenn
    // das Feld bereits eine @Getter-Annotation besitzt.
    if (!isField(getCursor()) || hasGetterAnnotation(v)) {
        return v;
    }

    // Prüfe, ob Getter-Methoden für alle Variablen in der Deklaration existieren.
    if (hasGetterMethods(v)) {
        v = addGetterAnnotation(v);

        // Fügt den Import von lombok.Getter an die Klasse hinzu, 
        // wenn er nocht nicht vorhanden ist
        maybeAddImport("lombok.Getter"); 
    }
    return v;
}
Implementierung der visitVariableDeclarations Visitor-Methode

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.

private boolean isField(Cursor cursor) {
    return cursor
            .dropParentUntil(parent -> parent instanceof J.ClassDeclaration
                    || parent instanceof J.MethodDeclaration)
            .getValue() instanceof J.ClassDeclaration;
}

Um herauszufinden, ob die Variablendeklaration bereits mit @Getter annotiert ist, können wir direkt eine der verfügbaren Methoden von J.VariableDeclarations verwenden:

private boolean hasGetterAnnotation(J.VariableDeclarations v) {
    return v.getAllAnnotations().stream()
            .anyMatch(it -> TypeUtils.isOfClassType(it.getType(), "lombok.Getter"));
}

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.

private boolean hasGetterMethod(J.VariableDeclarations v) {
    J.ClassDeclaration enclosingClass = getCursor()
            .firstEnclosingOrThrow(J.ClassDeclaration.class);

    return v.getVariables()
            .stream()
            .noneMatch(variable -> 
                findGetterMethods(enclosingClass, variable).isEmpty());
}

private Set<J.MethodDeclaration> findGetterMethods(
    J.ClassDeclaration enclosingClass, 
    J.VariableDeclaration.NamedVariable variable) {

    return FindMethods.findDeclaration(enclosingClass, 
        getterMethodPattern(variable));
}

private String getterMethodPattern(J.VariableDeclarations.NamedVariable v) {
    String prefix = TypeUtils.isOfClassType(v.getType(),"boolean") ? "is" : "get";
    return "* " + prefix + StringUtils.capitalize(it.getSimpleName()) + "()";
}

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:

private final JavaTemplate addAnnotation = 
        JavaTemplate.builder(this::getCursor, "@Getter")
            .imports("lombok.Getter")
            .javaParser(() -> JavaParser.fromJavaVersion()
                    .dependsOn("package lombok;"
                            + "import java.lang.annotation.ElementType;\n" +
                            "import java.lang.annotation.Retention;\n" +
                            "import java.lang.annotation.RetentionPolicy;\n" +
                            "import java.lang.annotation.Target;" +
                            "@Target({ElementType.FIELD, ElementType.TYPE})\n" +
                            "@Retention(RetentionPolicy.SOURCE)\n" +
                            "public @interface Getter {" +
                            "}")
                    .build())
            .build();

private J.VariableDeclaration addGetterAnnotation(J.VariableDeclaration v) {
    return v.withTemplate(addAnnotation, 
        v.getCoordinates()
         .addAnnotation(Comparator.comparing(J.Annotation::getSimpleName)));
}
JavaTemplate für die Lombok-Annotation

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:

Der Code für die visitClassDeclaration(...)-Methode sieht so aus:

@Override
public J.ClassDeclaration visitClassDeclaration(
        J.ClassDeclaration classDecl, ExecutionContext executionContext) {

    J.ClassDeclaration c = super.visitClassDeclaration(classDecl, 
        executionContext);

    // Suche alle Getter-Methoden für Felder, die eine @Getter Annotation haben
    var methodsToRemove =  c.getBody().getStatements().stream()
            .filter(it -> it instanceof J.VariableDeclarations)
            .map(it -> (J.VariableDeclarations) it)
            .filter(this::hasGetterAnnotation)
            .flatMap(it -> it.getVariables().stream())
            .flatMap(it -> findGetterMethods(classDecl, it).stream())
            .collect(Collectors.toSet());

    // Entferne diese Getter-Methoden aus der Klassendeklaration.
    var statements = c.getBody()
            .getStatements()
            .stream()
            .filter(statement -> {
                if (statement instanceof J.MethodDeclaration) {
                    J.MethodDeclaration method = (J.MethodDeclaration) statement;
                    return !methodsToRemove.contains(method);
                }
                return true;
            }).collect(Collectors.toList());

    c = c.withBody(c.getBody().withStatements(statements));

    // Liefere die angepasste Klassendeklaration zurück.
    return c;
}
Visitor-Methode für das Entfernen von Getter-Methoden

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:

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.

Fazit

Durch das Schreiben von Refactoring-Rezepten kannst Du das volle Potenzial von OpenRewrite nutzen, um Deine Softwareprojekte zu pflegen. Wie Du oben gesehen hast, kann es jedoch eine Herausforderung sein, sie selbst zu implementieren. Es ist daher am besten, sich weitgehend auf die Verwendung von den durch OpenRewrite selbst bereitgestellten Rezepten zu konzentrieren oder sich auf Sammlungen von Rezepten zu verlassen, die von Framework-Autor:innen geschrieben wurden. Glücklicherweise bieten immer mehr Frameworks Rezepte für ihre Abhängigkeitsupgrades an, sodass es in Zukunft weniger ein Problem sein sollte, die richtigen zu finden.