OpenRewrite Recipes mit Java erstellen
Im ersten Blogpost haben wir über OpenRewrite und die Möglichkeit, aus bestehenden Recipes neue zusammenzusetzen, berichtet. Was aber, wenn die bestehenden nicht ausreichen, um etwas zu migrieren? Dann muss man sie mit Java selbst schreiben. Wie das geht, schauen wir in diesem Blogpost an.
Eine kleine Einführung, wie OpenRewrite funktioniert
Damit wir mit Java Recipes schreiben können, müssen wir zuerst verstehen, wie OpenRewrite diese anwendet. OpenRewrite wandelt alle Source Files (Java, XML, YAML, Text und viele weitere mehr) in sogenannte Lossless Semantic Trees (LST) um. „Lossless“ bedeutet in diesem Zusammenhang, dass OpenRewrite auch die Formatierungen vollständig abbildet. Auf diesen Bäumen kommt das Visitor Pattern zum Einsatz, um die Recipes auszuführen. Ein Recipe besteht aus einem oder mehreren Visitors, und wir werden mit Java ein solches Recipe selbst implementieren.
Es gibt den generischen TreeVisitor,, der auf alle SourceFiles wirkt, sowie spezifische Visitors wie den JavaIsoVisitor, der sich gezielt auf Java-Dateien bezieht. Für den Anfang reicht dieses Wissen aus, um mit der Entwicklung unseres eigenen Recipes zu starten.
Entwicklungsumgebung aufsetzen
Wir benötigen ein neues Maven-Projekt für unser Recipe. Wie das genau geht, findest du das in der Anleitung. Dort finden sich weitere Informationen zum Schreiben von Recipes. Mittlerweile gibt es sogar ein GitHub-Template, mit dem man ein neues Projekt aufsetzen kann.
Und los geht’s
In Apache Kafka Streams sind seit Version 3.0 in der Klasse TimeWindows die Methoden of und grace deprecated. Falls of und grace verwendet werden, ist nun ofSizeAndGrace zu verwenden. Falls nur of verwendet wird, ist dieses durch ofSizeWithNoGrace zu ersetzen. Da es kein Eins-zu-eins-Ersatz ist, können wir leider nicht auf das Change Method Name Recipe zurückgreifen.
Das bedeutet, wir brauchen ein Recipe mit einem Visitor, der alle Methodenaufrufe durchsucht. Das sieht dann so aus:
public class TimeWindowsRecipe extends Recipe {
@Override
public @DisplayName @NotNull String getDisplayName() {
return "Migrate Deprecated TimeWindows Methods";
}
@Override
public @Description @NotNull String getDescription() {
return "Migrates the deprecated methods of and grace.";
}
@Override
public @NotNull TreeVisitor<?, ExecutionContext> getVisitor() {
return new JavaIsoVisitor<ExecutionContext>() {
@Override
public @NotNull MethodInvocation visitMethodInvocation(MethodInvocation method, ExecutionContext c) {
return super.visitMethodInvocation(method, c);
}
};
}
}
Als Nächstes empfiehlt es sich, einen Test zu schreiben, gegen den man entwickeln kann. Dafür gibt es von OpenRewrite viele kleine Helferlein. Wichtig ist, die org.apache.kafka:kafka-streams-Dependency hinzuzufügen, damit die Ad-hoc-Testklassen richtig geparst werden und im Tree alle Typinformationen vorhanden sind.
Zuerst stellen wir einen initialen kleinen Test auf:
public class TimeWindowTest implements RewriteTest {
@Override
public void defaults(RecipeSpec spec) {
spec.recipe(new TimeWindowsRecipe());
}
@Test
void migrateOf() {
rewriteRun(
java(
"""
package ch.puzzle.kafka.traffic.stream;
import java.time.Duration;
import org.apache.kafka.streams.kstream.TimeWindows;
public class Dummy{
void dummy(){
TimeWindows windows = TimeWindows.of(Duration.ofSeconds(10));
}
}
""",
"""
package ch.puzzle.kafka.traffic.stream;
import java.time.Duration;
import org.apache.kafka.streams.kstream.TimeWindows;
public class Dummy{
void dummy(){
TimeWindows windows = TimeWindows.ofSizeWithNoGrace(Duration.ofSeconds(10));
}
}
"""
)
);
}
}
Wir konzentrieren uns zunächst nur auf den Fall mit der Methode of und bauen darauf weiter auf. Im Visitor prüfen wir also, ob der aktuelle Methodenaufruf der of-Methode entspricht, und ersetzen ihn entsprechend. Dafür erweitern wir die Methode visitMethodInvocation wie folgt:
MethodMatcher ofMatcher = new MethodMatcher("org.apache.kafka.streams.kstream.TimeWindows of(..)");
private final JavaTemplate ofSizeWithNoGraceTemplate = JavaTemplate.builder("ofSizeWithNoGrace(#{})").contextSensitive().build();
@Override
public @NotNull MethodInvocation visitMethodInvocation(MethodInvocation method, ExecutionContext c) {
if (ofMatcher.matches(method)) {
final String argument = method.getArguments().get(0).print(getCursor());
return ofSizeWithNoGraceTemplate.apply(getCursor(), method.getCoordinates().replaceMethod(), argument);
}
return super.visitMethodInvocation(method, c);
}
Neu sind der MethodMatcher und das JavaTemplate.
- Der
MethodMatcherhilft uns herauszufinden, ob wir eineof-Methode vor uns haben. - Das
JavaTemplateist ein Mechanismus, um Java-Code passend zur Baumstruktur des LST zu generieren.
Mit diesen Hilfsmitteln gelingt die Migration nun ganz einfach. Zuerst überprüfen wir die Methode, danach verwenden wir die print-Methode – sie erzeugt aus der Baumstruktur den ursprünglichen String – um das Argument der of-Methode auszulesen. Zum Schluss führen wir das JavaTemplate aus und geben das Resultat zurück. Dabei ist es wichtig, beim JavaTemplate die Koordinaten anzugeben, an denen der generierte Code eingefügt werden soll. In unserem Beispiel betrifft das die gesamte Methode, aber es gibt auch Koordinaten für Fälle, in denen nur die Argumente ersetzt werden.
Als Nächstes migrieren wir den Fall, bei dem nach der of-Methode zusätzlich die grace-Methode aufgerufen wird. Den Code erweitern wir dafür wie folgt:
else if (graceMatcher.matches(method)
&& method.getSelect() instanceof MethodInvocation mi) {
final String graceArgument = method.getArguments().get(0).print(getCursor());
final String ofArgument = mi.getArguments().get(0).print(getCursor());
final MethodInvocation result = ofSizeAndGraceTemplate.apply(getCursor(), method.getCoordinates().replaceMethod(), ofArgument, graceArgument);
return result.withSelect(mi.getSelect());
}
Vieles ist aus dem ersten if-Statement bekannt, neu ist die getSelect-Methode, welche bei einem verketteten Methodenaufruf im Code das vorherige Element zurückgibt. In unserem Beispiel wäre das TimeWindows.of(Duration.ofSeconds(10)).grace(Duration.ofSeconds(10)), wobei das Select-Element des grace-Methodenaufrufs der of-Methodenaufruf wäre. Wir können also nun beide Argumente auslesen und dem Template übergeben. Im Unterschied zum vorherigen Code geben wir das Ergebnis des Templates nicht direkt zurück, sondern ersetzen noch das Select-Element mit dem Select-Element des of-Methodenaufrufs.
Abschluss
Um das Recipe nun in einem Projekt verwenden zu können, müssen wir das JAR-File mit dem Code als Dependency im entsprechenden Projekt hinzufügen. Anschließend können wir das Recipe entweder direkt oder in YAML-Recipes verwenden, indem wir es mit seinem Fully Qualified Classname – ch.puzzle.rewrite.kafka.TimeWindowsRecipe – referenzieren.
Fazit
OpenRewrite bietet im Recipe Katalog eine Vielzahl von Recipes an, von sehr umfassenden, die eine ganze Library migrieren, bis hin zu einfacheren, die nur den Methodennamen ersetzen. Bevor man also ein Recipe selbst schreibt, lohnt es sich, den Katalog zu durchsuchen. Manchmal ist ein Problem jedoch zu komplex oder es betrifft eine hauseigene Library, sodass man nicht umhin kommt, selbst ein Recipe zu schreiben. Wie wir nun gesehen haben, ist das keine große Hexerei. Und wenn man ein Recipe selbst geschrieben hat, das in den Katalog passen würde, freut sich die OpenRewrite-Community natürlich über die Contribution.
Der gesamte Code findet sich auf GitHub.