Java Magazin   9.2024 - Legacy: Wirklich ein Problem?

Preis: 9,80 €

Erhältlich ab:  August 2024

Umfang:  100

Autoren / Autorinnen: 
Marius Nied ,  
Stefan Jacobs ,  
Eberhard Wolff ,  
Eberhard Wolff ,  
Carola LilienthalHenning Schwentner ,  
Julius Mischok ,  
Piet Schijven ,  
Holger Herrmann ,  
Jan Leßner ,  
Yahya El Hadj AhmedJohannes LinkJan Hauer ,  
Markus ZillerMichael Graumann ,  
Vadym Kazulkin ,  
Karsten Sitterberg ,  
Bjarki Sigurðsson ,  
Frank Delporte ,  

Es lässt sich trefflich darüber streiten, ob der Begriff „Erbe“ eher positiv oder eher negativ konnotiert ist (ganz abgesehen davon, dass dem Erbe zunächst mal ein Abschied vorausgeht): Familien, die sich darüber entzweien, der Opernbühne entsprungene Zwerge, die mit der Aussicht auf „der Welt Erbe“ kurzerhand der Liebe entsagen (siehe Rheingold), vererbte Schulden und Verbindlichkeiten, die den Erben weniger in eine strahlende Zukunft als vielmehr schnurstracks in den Ruin führen – vorausgesetzt natürlich, er ist klug genug, es anzunehmen (und ja, hier setze ich dann doch das gute alte oder gar nicht so alte „Ironie off“) …

Kurz: Es scheint sich beim Begriff des Erbes, wenn nicht um einen negativen, so doch zumindest um einen zwiespältigen Begriff zu handeln, der sich auch in einem anderen, ideellen Zusammenhang in der Wendung „ein schweres Erbe“ niederschlägt. Und auch in unserer Branche ist der Begriff „Erbe“ eher negativ besetzt – auch wenn er natürlich ausschließlich auf Englisch auftaucht, nämlich als „Legacy“. Mit Legacy verbinden die meisten wohl die Problematik, mit altem, schwer änderbarem Code zu arbeiten. Mit einer strahlenden Zukunft verbindet man das wohl eher nicht, da scheint dann doch wieder der Stante-Pede-Ruin von oben auf – naja, vielleicht nicht ganz so drastisch. Aber das Erbe einfach ausschlagen? Das ist in vielen Fällen schlichtweg nicht möglich. Die Lösung kann also nur sein, das Erbe anzunehmen und zu schauen, ob es wirklich ein so schweres ist.

Im Schwerpunkt dieser Ausgabe zeigt uns Eberhard Wolff, wieso Legacy auch Vorteile bieten kann, warum es sich lohnt, diese zu kennen und warum der schlechte Ruf, den Legacy hat, zumindest ein wenig revidiert werden sollte. Im gleichen Atemzug startet Eberhard eine neue Kolumne, die Legacy als soziales Problem betrachtet und Auftakt einer Reihe soziotechnischer Betrachtungen sein wird.

Doch unser Schwerpunkt hat natürlich noch mehr zu bieten: Während sich Julius Mischok in seinem Artikel der alles entscheidenden Frage „Retten oder reimplementieren?“ widmet, beschäftigen sich Carola Lilienthal und Henning Schwentner mit dem Thema „Domain-Driven Transformation“. Last but noch least zeigt Piet Schijven, wie man Legacy-Software mit OpenRewrite, einem Framework für automatisierte Code-Refactorings, modernisieren kann.

Zusammengenommen also ein Schwerpunkt, der dem Angstthema Legacy hoffentlich etwas von seiner Schwere nimmt und Ihnen dabei – neben drei neuen Serien und zahlreichen weiteren Artikeln – eine möglichst kurzweilige und erkenntnisreiche Lektüre bietet, bei der wir Ihnen viel Freude wünschen!

Die Modernisierung von Legacy-Software ist eine Herausforderung, die viele Unternehmen vor erhebliche Schwierigkeiten stellt. Mit OpenRewrite, einem Framework für automatisierte Code-Refactorings, können veraltete Systeme effizient aktualisiert werden.

Während der Modernisierung eines Legacy-Systems ist es wichtig, dass die alten Systeme weiterhin zuverlässig gewartet werden. Es kann jedoch herausfordernd sein, diese Systeme mit den neuesten Sicherheitspatches und Framework-Updates aktuell zu halten. In der Regel müssen die Projekte zuerst manuell aktualisiert, danach getestet und schließlich in Produktion gebracht werden. Insbesondere bei älteren Projekten, die nicht mehr aktiv weiterentwickelt werden, ist dafür oft keine Zeit, z. B. weil die Mitarbeiter:innen anderweitig eingebunden sind.

Große Framework-Upgrades können ebenfalls problematisch sein, insbesondere bei Softwareprojekten, die von mehr als einem Team entwickelt werden. Diese Migrationen werden üblicherweise parallel zum normalen Entwicklungsstand umgesetzt. Aufgrund von Funktionen, die von anderen Projektmitgliedern in der Zwischenzeit entwickelt werden, ist dieser aber ständig veraltet. Die Durchführung der endgültigen Integration ist daher fehleranfällig und erfordert eine umfangreiche Koordination innerhalb des Projekts.

Glücklicherweise sind in den vergangenen Jahren mehrere Ansätze auf dem Markt erschienen, die es ermöglichen, die Codewartung größtenteils zu automatisieren und den Mitarbeiter:innen mehr Zeit für andere Aufgaben zu geben. Eine dieser Lösungen ist OpenRewrite [1]. Im Folgenden geht es darum, was OpenRewrite ist und wie es in der Praxis genutzt werden kann, um die Modernisierung von Legacy-Software effizienter zu gestalten.

Lizenzhinweis

Die in diesem Artikel verwendeten Codebeispiele stammen teilweise aus dem OpenRewrite-Projekt und sind unter der Apache License 2.0 lizenziert. Der vollständige Lizenztext kann unter http://www.apache.org/licenses/LICENSE-2.0 eingesehen werden. Die Codebeispiele wurden für diesen Artikel angepasst.

Was ist OpenRewrite und wie funktioniert es?

OpenRewrite ist eine Bibliothek, die für die Codewartung in Softwareprojekten eingesetzt werden kann. Ihre Hauptfunktion ist die automatische Änderung von Code durch die Anwendung von „Rezepten“ auf das Projekt, z. B.:

  • Behebung von Problemen, die von statischen Analysewerkzeugen gemeldet wurden

  • automatische Formatierung des Quellcodes

  • Aktualisierung der Java-Version

  • Upgrades bekannter Frameworks (JUnit, Spring Boot, …)

Mittlerweile wurden Rezepte für verschiedene Sprachen und Technologien bereitgestellt, u. a. für Java, Kotlin, Python, Kubernetes und Terraform [2]. Falls kein passendes Rezept vorhanden ist, kann man auch selbst eins in Java erstellen. Aufgrund der Komplexität der Bibliothek ist das jedoch eine herausfordernde Aufgabe. Es lohnt sich daher, sich auf die Kombination kleiner vorhandener Refactoring-Rezepte zu konzentrieren.

Wenn OpenRewrite Rezepte auf eine Codebasis anwendet, konstruiert es eine Baumdarstellung des betreffenden Codes. Dieser Baum ist im Wesentlichen eine weiterentwickelte Version des Abstract Syntax Tree (AST) [3]. Er liefert nicht nur die grundlegenden Informationen, die der Compiler benötigt, um den Code zu kompilieren, sondern hat auch zusätzliche strukturelle Eigenschaften. In dem Baum werden Informationen über die Leerzeichen vor und nach den Baumelementen gespeichert. So bleibt die ursprüngliche Formatierung des Codes bei der Ausführung erhalten.

Zusätzlich besitzt jedes Baumelement detaillierte Typinformationen, auch wenn diese Typen nicht explizit im Source Code selbst definiert sind (wenn z. B. eine Variable vorher im Code deklariert wurde und an dieser Stelle nur verwendet wird, enthält das entsprechende Baumelement auch die Typinformationen der Variable). Der Baum ist darüber hinaus vollständig serialisierbar. Das ermöglicht es, den Baum im Voraus zu berechnen und für die zukünftige Verarbeitung zu speichern. Ein AST mit diesen zusätzlichen Eigenschaften wird als „Lossless Semantic Tree“ oder LST bezeichnet [4].

OpenRewrite kann leicht in den Build-Prozess integriert werden, indem das OpenRewrite-Maven-oder -Gradle-Plug-in verwendet wird. In der Konfiguration des Plug-ins wird angegeben, welche Rezepte für das aktuelle Projekt aktiviert werden sollen (Listing 1).

Listing 1: Anbindung des OpenRewrite Maven Plug-in

<plugin>
  <groupId>org.openrewrite.maven</groupId>
  <artifactId>rewrite-maven-plugin</artifactId>
  <version>5.31.0</version>
  <configuration>
    <activeRecipes>
      <!-- Verwende den fully qualified name vom Rezept oder den Namen in rewrite.yml -->
      <recipe>...</recipe> 
      ...
    </activeRecipes>
  </configuration>
  <dependencies>
    <!-- Deklaration der Abhängigkeiten für Rezepte, die nicht durch OpenRewrite mitgeliefert werden -->
    ...
  </dependencies>
</plugin>

Durch die Ausführung von mvn rewrite:run werden die OpenRewrite-Rezepte ausgeführt und der Source Code wird entsprechend geändert. Im Anschluss kannst du die Änderungen überprüfen und committen. Wenn das Rezept, das ausgeführt werden soll, zusätzliche Konfigurationsparameter erfordert, muss eine Datei rewrite.yml definiert und im Hauptverzeichnis des Projekts oder unter META-INF/rewrite abgelegt werden. In dieser Datei kann eine beliebige Anzahl von Rezepten oder Zusammenstellungen von Rezepten angegeben werden. Innerhalb der Konfiguration des Maven oder Gradle Plug-ins kann über den Namen genau das Rezept gewählt werden, das ausgeführt werden soll. Wenn du beispielsweise mit OpenRewrite die Version von Hibernate in deinem Projekt ändern möchtest, sieht rewrite.yml so aus wie in Listing 2.

Listing 2: Beispiel einer rewrite.yml-Datei

type: specs.openrewrite.org/v1beta/recipe
name: com.myorg.UpgradeHibernate
displayName: Upgrades the Hibernate Version
recipeList:
  - org.openrewrite.maven.UpgradeDependencyVersion:
      groupId: org.hibernate.orm
      artifactId: hibernate-core
      newVersion: 6.5.2.Final</plugin>

Wie OpenRewrite in der Praxis eingesetzt werden kann, möchte ich nun anhand eines Beispiels illustrieren.

Framework-Modernisierung für eine Legacy-Anwendung

Ein großes Unternehmen betreibt und entwickelt eine interne Java-Anwendung, die von vielen Abteilungen im Unternehmen genutzt wird. Insgesamt arbeiten viele Entwickler:innen an dem Projekt, wodurch täglich viele Commits und Releases der Software entstehen. Trotz der Einführung moderner Softwareentwicklungsmethoden wie einer guten Testabdeckung, CI/CD Pipelines und automatisierter Deployments wurden im Lauf der Jahre die Bibliotheken und Frameworks zu selten aktualisiert. Weil so viele Leute gleichzeitig an der Anwendung gearbeitet haben, war es immer sehr schwierig, große Framework-Updates durchzuführen: Der Branch für das Upgrade war ständig veraltet, es entstanden zu viele Merge-Konflikte bei der Integration der Änderungen, es mussten zu viele Features entwickelt werden oder das Risiko, das nächste Release der Anwendung zu verzögern, war zu groß. Mit der Zeit wurde es immer schwieriger, Updates durchzuführen.

In diesem Fall kann OpenRewrite Unterstützung bieten, ohne mit den aktuellen Entwicklungstätigkeiten zu interferieren. Die Idee ist, ein zusammengesetztes OpenRewrite-Rezept in Form einer rewrite.yml für die Migration zu erstellen und sie im Repository auf dem develop Branch abzulegen. Innerhalb der CI/CD Pipeline können wir die Migration verproben, indem wir OpenRewrite in einer separaten Build Stage ausführen und danach die Tests erneut laufen lassen. Sobald diese Build Stage erfolgreich durchläuft, können wir sicher sein, dass die Migration funktioniert. Diese Vorgehensweise erlaubt es den Entwickler:innen, die Migration Schritt für Schritt kontrolliert umzusetzen und sie immer auf dem aktuellen Codestand zu verproben.

Nehmen wir nun an, dass die Anwendung auf Basis von JEE 8 erstellt wurde. Das Unternehmen möchte die Anwendung unter anderem auf Jakarta EE 10 aktualisieren und Java 21 als Runtime verwenden. Für die Migration erstellen wir deshalb das OpenRewrite-Rezept in Listing 3, das die mitgelieferten Rezepte org.openrewrite.java.migrate.UpgradetoJava21 und org.openrewrite.java.migrate.jakarta.JakartaEE10 kombiniert. Die CI/CD Pipeline in GitLab könnte dann aussehen wie in Listing 4.

Listing 3: OpenRewrite-Rezept für die JEE-10- und Java-21-Migration

rewrite.yml

type: specs.openrewrite.org/v1beta/recipe
name: com.myorg.ModernizeMyApplication
displayName: Migrate to JEE 10 and Java 21
description: Migration nach JEE 10 und Java 21.
recipeList:
  - org.openrewrite.java.migrate.UpgradeToJava21
  - org.openrewrite.java.migrate.jakarta.JakartaEE10

Listing 4: GitLab CI/CD Pipeline für die Migration

stages:
  - test
  - build
  - openrewrite

# Stages für den normalen Build
...

run_migration:
  stage: openrewrite
  image: maven:3.8.5-jdk-21
  allow_failure: true
  only: 
    - develop
  script:
    - mvn rewrite:run -Drewrite.configLocation=rewrite.yml
    - mvn clean test

Sobald die OpenRewrite Stage in der Pipeline erfolgreich durchläuft, kann ein:e Entwickler:in die Migration lokal durchführen und den endgültigen Commit für die Migration erstellen.

Durch die Integration von OpenRewrite in die CI Pipeline haben wir die folgenden Vorteile im Vergleich zu einer überwiegend manuellen Vorgehensweise:

  • Die Migration wird automatisiert, was zu konsistenten Änderungen und Wiederholbarkeit führt und die Wahrscheinlichkeit menschlicher Fehler reduziert.

  • Da die Migration immer auf dem aktuellen Stand in develop verprobt wird, muss kein langlebiger Feature-Branch genutzt werden, der schnell veraltet und möglicherweise viele Merge-Konflikte verursacht.

  • Die Entwickler:innen können sich auf die eigentliche Entwicklungsarbeit konzentrieren, während OpenRewrite die Migration automatisiert und beschleunigt.

Dieses Beispiel zeigt, wie OpenRewrite effektiv für große Framework-Migrationen in Legacy-Software eingesetzt werden kann. Wichtig ist jedoch, zu realisieren, dass nicht alles einfach mit OpenRewrite gelöst werden kann. Die Anwendung könnte beispielsweise Bibliotheken verwenden, die nicht mit der neuen Frameworkversion kompatibel sind. Diese müssen entweder zuerst manuell aktualisiert oder sogar entfernt werden, bevor die Migration mit OpenRewrite erfolgreich durchgeführt werden kann. Dennoch nimmt OpenRewrite hier viel Arbeit ab, insbesondere bei den mitgelieferten Rezepten für JEE oder Spring Boot Upgrades, da diese teilweise von den Framework-Herstellern selbst überprüft und erstellt wurden. Man kann daher (relativ) sicher sein, dass alle Änderungen gut abgedeckt werden.

OpenRewrite als flexibles Refactoring-Tool in der IDE

Eine sehr spannende neue Entwicklung ist die Integration von OpenRewrite als Plug-in in gängigen Entwicklungsumgebungen wie IntelliJ (ab Version 2024.1) [5], Eclipse und Visual Studio Code [6]. In den neuesten Versionen von IntelliJ ist OpenRewrite standardmäßig als Plug-in enthalten, was es Entwickler:innen ermöglicht, komplexe Refactoring-Aufgaben direkt in der IDE auszuführen. Die Integration in Eclipse und Visual Studio Code über die Spring-Tools ist etwas eingeschränkter. Hier liegt der Fokus hauptsächlich darauf, die Framework-Upgrades von Spring-Boot-Projekten zu vereinfachen.

Das IntelliJ-Plug-in empfinde ich als eine großartige Weiterentwicklung in der Benutzerfreundlichkeit von OpenRewrite. Jetzt kann man beim Entwickeln nicht nur die Standard-Refactoring-Tools von IntelliJ verwenden, sondern auch komplexere Änderungen mit Hilfe von OpenRewrite-Rezepten durchführen. Es besteht nun die Möglichkeit, OpenRewrite-Rezepte als temporäre YML-Dateien (z. B. als Scratch-File) zu erstellen und sie direkt auszuführen. Die Änderungen sind sofort sichtbar und können danach bei Bedarf ins Git-Repository übernommen werden. Ein großer Vorteil ist auch, dass nun die Build-Datei nicht mehr angepasst werden muss, um Rezepte ausführen zu können.

OpenRewrite Deep Dive

Um besser zu verstehen, wie OpenRewrite-Rezepte geschrieben werden können, lohnt es sich, den Source Code eines einfachen Rezepts anzuschauen. Als Beispiel nehmen wir das Rezept ChangeAnnotationAttributeName [7]. Mit dessen Hilfe kann man ein Argument einer Annotation umbenennen. Wenn etwa die Annotation @Test(timeout = 200) im Source Code vorhanden ist, kann diese in @Test(waitFor = 200) umbenannt werden.

OpenRewrite-Rezepte werden als Java-Klassen definiert, die von der abstrakten Basisklasse org.openrewrite.Recipe erben. Wie vorher erwähnt, erstellt OpenRewrite für jede zu transformierende Klasse eine Baumstruktur (LST). Innerhalb eines Rezepts wird das Visitor Pattern benutzt, um durch diesen Baum zu traversieren und die erforderlichen Transformationen anzuwenden. Konkret wird der Visitor durch die Klasse TreeVisitor definiert. Ein Rezept besteht deshalb größtenteils aus einer konkreten Implementierung von TreeVisitor, in der die Callback-Methoden für die relevanten Baumelemente implementiert werden.

Damit diese Diskussion etwas anschaulicher wird, definieren wir zuerst eine sehr einfache Beispielklasse, auf die das Rezept angewendet werden soll (Listing 5).

Listing 5: Beispiel-Klasse für das ChangeAnnotationAttributeName-Rezept

package com.myorg.beispiel;

@MeinAnnotation(testWert = "test")
public class BeispielKlasse {

  public void doSomething() {
    System.out.println("Something!");
  }

}

Mit OpenRewrite kann man sich den LST für diese Klasse generieren lassen [8]. Das Ergebnis sieht so aus wie in Listing 6 gezeigt.

Listing 6: Beispiel eines LST

----J.ClassDeclaration
    |---J.Annotation | "@MeinAnnotation(testWert = "test")"
    |   |-----------J.Assignment | "testWert = "test""
    |   |           |---J.Identifier | "testWert"
    |   |           \-------J.Literal
    |   \---J.Identifier | "MeinAnnotation"
    |---J.Modifier | "public"
    |---J.Identifier | "BeispielKlasse"
    \---J.Block
        \-------J.MethodDeclaration | "MethodDeclaration{com.myorg.beispiel.BeispielKlasse{name=doSomething,return=void,parameters=[]}}"
                |---J.Modifier | "public"
                |---J.Primitive | "void"
                |---J.Identifier | "doSomething"
                |-----------J.Empty
                \---J.Block
                    \-------J.MethodInvocation | "System.out.println("Something!")"
                            |-------J.FieldAccess | "System.out"
                            |       |---J.Identifier | "System"
                            |       \-------J.Identifier | "out"
                            |---J.Identifier | "println"
                            \-----------J.Literal

Wie man sehen kann, sind alle Elemente im LST als interne Klassen (und Implementierungen) des Interface J definiert, das die Baumimplementierung für Java-Quelldateien ist. Der Visitor im Rezept verwendet Callback-Methoden, um diese Elemente transformieren zu können, wie z. B. die Methode public J.ClassDecleration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext context), um eine Klassendeklaration zu transformieren. Der Methodenparameter classDeclr stellt das Originalelement dar, und der Rückgabewert ist die veränderte Klassendeklaration. Für die anderen Arten von LST-Elementen gibt es ähnliche Callback-Methoden.

Das Rezept ChangeAnnotationAttributeName besitzt drei Konfigurationsparameter, um sowohl die Annotation als auch den alten und neuen Namen des Attributs angeben zu können. Eine etwas vereinfachte Version des Originalrezepts [9] zeigt Listing 7.

Listing 7: Code für das ChangeAnnotationAttributeName-Rezept

// Beispielcode aus dem OpenRewrite-Projekt, lizenziert unter der Apache License 2.0
// Siehe: http://www.apache.org/licenses/LICENSE-2.0

package org.openrewrite.java;

// Import Statements
...

public class ChangeAnnotationAttributeName extends Recipe {

  @Option String annotationType;
  @Option String oldAttributeName;
  @Option String newAttributeName;

  @Override
  public String getDisplayName() {
    return "Change annotation attribute name";
  }

  @Override
  public TreeVisitor<?, ExecutionContext> getVisitor() {
    return new JavaIsoVisitor<ExecutionContext>() {
      private final AnnotationMatcher annotationMatcher = new AnnotationMatcher(annotationType);

      @Override
      public J.Annotation visitAnnotation(J.Annotation annotation, ExecutionContext ctx) {
        // Implementierung der Visitor für das Rezept.
      }
    }
  }
}

Mithilfe der @Option-Annotation werden die Konfigurationsparameter für das Rezept deklariert. Innerhalb der getVisitor()-Methode wird der Visitor für das Rezept definiert. In diesem Fall implementiert das Rezept nur die Callback-Methode visitAnnotation, weil ausschließlich Änderungen an Annotationen durchgeführt werden sollen. Um sicherzustellen, dass nur die gewünschte Annotation verändert wird, benutzt das Rezept die Hilfsklasse AnnotationMatcher. Die genaue Implementierung der Callback-Methode ist in Listing 8 zu sehen.

Listing 8: Kernlogik des Rezepts, um Annotation-Attribute umzubenennen

// Beispielcode aus dem OpenRewrite-Projekt, lizenziert unter der Apache License 2.0
// Siehe: http://www.apache.org/licenses/LICENSE-2.0

// Schritt 1: 
J.Annotation a = super.visitAnnotation(annotation, ctx);

// Schritt 2:
if (!annotationMatcher.matches(a)) {
  return a;
}

// Schritt 3:
return a.withArguments(ListUtils.map(a.getArguments(), arg -> {
  if (arg instanceof J.Assignment) {
    if (!oldAttributeName.equals(newAttributeName)) {
      J.Assignment assignment = (J.Assignment) arg;
      J.Identifier variable = (J.Identifier) assignment.getVariable();
      if (oldAttributeName.equals(variable.getSimpleName())) {
        return assignment.withVariable(
          variable.withSimpleName(newAttributeName));
      }
    }
  } else if (oldAttributeName.equals("value")) {
    J.Identifier name = new J.Identifier(randomId(), 
      arg.getPrefix(), Markers.EMPTY, emptyList(), newAttributeName,
      arg.getType(), null);
  
    return new J.Assignment(randomId(), EMPTY, arg.getMarkers(), name, 
      new JLeftPadded<>(SINGLE_SPACE, arg.withPrefix(SINGLE_SPACE), Markers.EMPTY), arg.getType());
  }
  return arg;
}));

Analysieren wir nun genau, was in dieser Methode passiert:

  • Schritt 1: Am Anfang wird super.visitAnnotation(annotation, ctx) aufgerufen, damit zuerst der Unterbaum vom Visitor besucht und ggf. transformiert wird. Effektiv bedeutet das, dass dieser Visitor eine Art Post-Processing für das Baumelement durchführt. Wenn man die super-Methode erst am Ende der Methode aufruft, hat man eine Art Pre-Processing des Baumelements. Für dieses Rezept ist das nicht wichtig, aber wenn mehrere Callback-Methoden verwendet werden, kann man hiermit die Reihenfolge der Transformationen beeinflussen.

  • Schritt 2: Wenn nicht die gewünschte Annotation besucht wird, geschieht nichts.

  • Schritt 3: Hier wird die genaue Transformationslogik definiert. Im ersten Block im if-Statement wird der Name des Attributs geändert, in dem die Attributzuweisung (J.Assignment) bestimmt wird und der Variablenname über den J.Identifier geändert wird. Wichtig ist hier, dass man die Änderungen nie direkt am Baumelement durchführt, sondern immer eine neue Instanz über eine passende Factory-Methode erzeugt (hier z. B. mit assignment.withVariable und variable.withSimpleName). Der zweite Block behandelt den Sonderfall, dass oldValue gleich "value" ist. Das ist z. B. der Fall, wenn man @MeineAnnotation("test") in @MeineAnnotation(newValue = "test") ändern möchte. Hier wird dann eine komplett neue Instanz von J.Assignment erzeugt.

Obwohl dieses Rezept eigentlich sehr einfach ist, beinhaltet es trotzdem viele kleine Details. Beim Schreiben von Rezepten muss man daher sehr gut aufpassen, alle möglichen Varianten im Java-Code zu prüfen. Um etwas mehr Sicherheit zu gewinnen, sollte man daher auch Unit-Tests für das Rezept erstellen. Glücklicherweise bietet OpenRewrite hier gute Unterstützung. Die Testklasse soll das Interface RewriteTest implementieren und die Methode defaults überschreiben, um das relevante Rezept für den Test zu definieren. In unserem Fall könnte diese Testklasse so aussehen, wie es Listing 9 zeigt.

Listing 9: Das Testen von OpenRewrite-Rezepten

class ChangeAnnotationAttributeNameTest implements RewriteTest {

  @Override
  public void defaults(RecipeSpec spec) { 
    spec.recipe(new ChangeAnnotationAttributeName()) 
  }

  @Test
  void testChangeAttributeName() {
    rewriteRun(
      java(" // Original Quellcode als String ", " // Erwartetes Ergebnis nach der Ausführung des Rezepts"))
    )
  }
}

Um komplexere Rezepte zu testen, kann man auch eine rewrite.yml-Datei anstatt der Klasse des Rezepts angeben. Innerhalb der Testmethode führt man die Transformation mit rewriteRun durch, indem man mit der java-Methode den zu transformierenden Java-Code und das erwartete Ergebnis übergibt.

Fazit

OpenRewrite bietet eine leistungsstarke und flexible Lösung zur Modernisierung von Legacy-Software. Durch die Automatisierung von Wartungsaufgaben wie Sicherheitsupdates, Framework-Upgrades und Codeformatierung können Entwickler:innen erhebliche Zeit und Ressourcen sparen. Die Integration von OpenRewrite in den Build-Prozess und die Verwendung maßgeschneiderter Rezepte ermöglicht eine konsistente und fehlerfreie Umsetzung von Änderungen. Besonders nützlich ist die Möglichkeit, komplexe Migrationsaufgaben in einer CI/CD Pipeline zu verproben und sicherzustellen, dass alle Änderungen auf dem aktuellen Codestand basieren.

Trotz der vielen Vorteile von OpenRewrite ist es wichtig zu beachten, dass nicht alle Probleme automatisch gelöst werden können. Manuelle Eingriffe sind manchmal erforderlich, insbesondere bei inkompatiblen Bibliotheken oder spezifischen Anforderungen, die nicht durch bestehende Rezepte abgedeckt werden. Dennoch bietet OpenRewrite erhebliche Erleichterungen, insbesondere bei der Durchführung großer Framework-Migrationen und der kontinuierlichen Wartung von Code. Mit der Integration in beliebte IDEs und der Bereitstellung detaillierter Rezeptbeispiele ist OpenRewrite ein praktisches Werkzeug für die moderne Softwareentwicklung und die Wartung von Legacy-Systemen.

In dieser Serie werden wir mehrere Möglichkeiten – basierend auf unterschiedlichen Frameworks oder Tools – demonstrieren, wie Spring-Boot-3-Anwendungen auf AWS Lambda mit Java 21 umgesetzt, optimiert und betrieben werden können. Zum Auftakt schauen wir uns AWS Serverless Java Container an.

Wir werden verschiedene Möglichkeiten zur Ausführung von Spring-Boot-3-Anwendungen auf AWS Lambda in den Artikeln dieser Serie unter Verwendung der folgenden Frameworks, Technologien und Tools untersuchen:

  • AWS Serverless Java Container

  • AWS-Lambda-Web-Adapter

  • Spring Cloud Function

  • Benutzerdefiniertes Docker Image

In allen Fällen stellen wir zunächst das Konzept dahinter vor und erfahren dann, wie wir unsere Anwendung mit dem jeweiligen Ansatz entwickeln, bereitstellen und betreiben. Wir werden uns auch GraalVM Native Image unter Verwendung von Spring Cloud Function als eine Option anschauen, die als AWS Lambda Custom Runtime bereitgestellt wird. Zu guter Letzt werden wir untersuchen, ob die native Unterstützung von Coordinated Restore at Checkpoint (CRaC) in Spring Boot 3 ebenfalls ein valider Ansatz ist.

Natürlich werden wir die Kalt- und Warmstartzeiten der Lambda-Funktion mit allen genannten Ansätzen messen und die vorgestellten Lösungen vergleichen. Außerdem werden wir sehen, wie wir die Kaltstarts der Lambda-Funktionen mit SnapStart (einschließlich verschiedener Priming-Techniken) optimieren können, falls es für den jeweiligen Ansatz verfügbar ist (derzeit z. B. nicht bei den Docker-Container-Images). Codebeispiele für die gesamte Serie findet ihr auf meinem GitHub-Account [1].

Einführung

Der AWS Serverless Java Container erleichtert die Ausführung von Java-Anwendungen, die mit Frameworks wie Spring, Spring Boot 2 und 3 oder JAX-RS/Jersey in Lambda geschrieben wurden [2]. Der Container bietet Adapterlogik, um Codeänderungen zu minimieren. Eingehende Ereignisse werden in die Servlet-Spezifikation übersetzt, sodass die Frameworks wie bisher eingesetzt werden können (Abb. 1) [3].

kazulkin_spring_lambda_1_1

Abb 1: Architektur des AWS Serverless Java Container

AWS Serverless Java Container stellt den Kerncontainer und frameworkspezifische Container wie den für Spring Boot 3 zur Verfügung, der in diesem Artikel im Mittelpunkt unseres Interesses steht [4], [5]. Es gibt auch andere Container für die Frameworks Spring, Struts und Jersey. Ein Major-Update auf Version 2.0 wurde kürzlich für alle AWS Serverless Java Container veröffentlicht. Wenn wir uns den gesamten Abhängigkeitsbaum ansehen, werden wir eine weitere Abhängigkeit spring-cloud-function-serverless-web entdecken, die das Artefakt aws-serverless-java-container-springboot3 benötigt, ein Ergebnis der Zusammenarbeit von Spring- und AWS-Serverless-Entwicklern [6]. Es bietet Spring Cloud Function auf AWS-Lambda-Funktionalität [7]. Dessen Möglichkeiten betrachten wir in einem der kommenden Serienteile.

AWS-Serverless Java Core Container bietet auch Abstraktionen wie AWSProxyRequest/Response für die Abbildung von API-Gateway-(REST-)Anfragen auf das Servlet-Modell einschließlich verschiedener Authorizer wie Amazon Cognito und HttpApiV2JwtAuthorizer. Im Kerncontainer wird alles durch die AwsHttpServletRequest/Response-Abstraktionen oder deren Derivate wie AwsProxyHttpServletRequest durchgereicht.

Mein persönlicher Wunsch ist es, dass eine Untermenge von Abstraktionen, z. B. aus dem Java-Package com.amazonaws.serverless.proxy.model, wie

  • AwsProxyRequest

  • ApiGatewayRequestIdentity

  • AwsProxyRequestContext

  • AwsProxyResponse

und andere Teil eines separaten Projekts werden und daher auch ohne die Verwendung aller anderen AWS Serverless Java Container APIs nur zum Mocking der API Gateway Request/Response (d. h. für das Priming) verwendet werden können. Wir werden diese Abstraktionen in einem der kommenden Artikel direkt nutzen, wenn wir Verbesserungen der Kalt- und Warmstartzeiten für Spring-Boot-3-Anwendungen auf AWS Lambda unter Verwendung von AWS Lambda SnapStart in Verbindung mit Priming-Techniken unter die Lupe nehmen. Eine Einführung in AWS Lambda SnapStart findet ihr unter [8].

Die Lambda Runtime muss wissen, welche Handler-Methode aufgerufen werden soll. Zu diesem Zweck fügt der AWS-Serverless-Spring-Boot-3-Container, der intern den AWS Serverless Java Core Container verwendet, lediglich einige Implementierungen hinzu, wie z. B. SpringDelegatingLambdaContainerHandler, oder wir implementieren unsere eigene Java-Handler-Klasse, die an den AWS Serverless Java Container delegiert. Das ist nützlich, wenn wir zusätzliche Funktionen wie die Lambda-SnapStart-Priming-Technik implementieren möchten [8]. Dazu kann die Abstraktion SpringBootLambdaContainerHandler (die die Klasse AwsLambdaServletContainerHandler vom Kerncontainer erbt) verwendet werden, die durch Übergabe der Haupt-Spring-Boot-Klasse, die mit @SpringBootApplication annotiert ist, als Input erstellt werden kann. Für Spring-Boot-3-Anwendungen, die länger als zehn Sekunden zum Starten benötigen, gibt es einen asynchronen Weg, SpringBootLambdaContainerHandler zu erstellen, indem die SpringBootProxyHandlerBuilder-Abstraktion verwendet wird. Seit Version 2.0.0 läuft sie standardmäßig immer asynchron, in den vorherigen Versionen mussten wir die Methode asyncInit aufrufen (was jetzt veraltet ist), um SpringBootProxyHandlerBuilder asynchron zu initialisieren. Ich werde das im weiteren Verlauf dieses Artikels anhand von Codebeispielen genauer erklären.

Die Anwendung entwickeln

Zur Erläuterung verwenden wir unsere Spring-Boot-3-Beispielanwendung und nutzen die Java 21 Runtime für unsere Lambda-Funktionen (Abb. 2) [9].

kazulkin_spring_lambda_1_2

Abb. 2: Architektur der Beispielanwendung

In dieser Anwendung werden wir Produkte erstellen und sie nach deren ID abrufen, wobei wir Amazon DynamoDB als NoSQL-Datenbank für die Persistenzschicht verwenden. Wir nutzen Amazon API Gateway, das das Erstellen, Veröffentlichen, Warten, Überwachen und Sichern von APIs für Entwickler vereinfacht. Außerdem nutzen wir AWS SAM, das eine Kurzsyntax anbietet, die für die Definition von Infrastructure as Code (nachfolgend IaC) für serverlose Anwendungen optimiert ist. Den vollständigen Code von Product Controller (ProductController-Klasse), DynamoDB-Persistierungslogik (DynamoProductDAO-Klasse), Request-Stream-Handler-Implementierung (StreamLambdaHandler-Klasse) und IaC basierend auf AWS SAM (template.yaml) findet ihr auf meinem GitHub-Repository [9].

Um diese Anwendung zu bauen, müssen wir mvn clean package ausführen. Um sie zu deployen, müssen wir sam deploy -g in dem Verzeichnis ausführen, in dem sich das SAM-Template (template.yaml) befindet. Als Rückgabe erhalten wir den individuellen Amazon API Gateway URL. Wir können ihn nutzen, um Produkte zu erstellen und nach ihrer ID abzurufen. Die Schnittstelle ist mit dem API Key abgesichert (als HTTP-Header müssen wir Folgendes mitschicken: "X-API-Key: a6ZbcDefQW12BN56WEA7"). Um das Produkt mit der ID 1 zu erzeugen, müssen wir z. B. mit Curl folgende Abfrage absetzen:

curl -m PUT -d '{ "id": 1, "name": "Print 10x13", "price": 0.15 }‘ -H "X-API-Key: a6ZbcDefQW12BN56WEA7" https://{$API_GATEWAY_URL}/prod/products 

Um das bestehende Produkt mit der ID 1 abzufragen, muss folgende Curl-Abfrage abgesetzt werden:

curl -H "X-API-Key: a6ZbcDefQW12BN56WEA7" https://{$API_GATEWAY_URL}/prod/products/1 

Jetzt schauen wir uns relevante Quellcodefragmente an. Die Spring-Boot-3-ProductController-Klasse, annotiert mit @RestController und @EnableWebMvc definiert die Methoden getProductById und createProduct (Listing 1).

Listing 1

@RequestMapping(path = "/products/{id}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
  public Optional<Produkt> getProductById(@PathVariable(„id“) String id) {
    return productDao.getProduct(id);
  }

@RequestMapping(path = "/products/{id}", method = RequestMethod.PUT, consumes = MediaType.APPLICATION_JSON_VALUE)
  public void createProduct(@PathVariable(„id“) String id, @RequestBody Product product) {
    product.setId(id);
    productDao.putProduct(produkt);
  }

Die Hauptabhängigkeit für die Funktion und die Übersetzung zwischen dem Spring-Boot-3-Modell (Webannotation) und AWS Lambda ist die Abhängigkeit vom Artefakt aws-serverless-java-container-springboot3, das in der pom.xml definiert ist:

<dependency>
  <groupId>com.amazonaws.serverless</groupId>
  <artifactId>aws-serverless-java-container-springboot3</artifactId>
  <version>2.0.0</version>
</dependency>

Es basiert auf dem Serverless Java Container, der die Proxy-Integrationsmodelle des API Gateway für Anfragen und Antworten nativ unterstützt, und wir können benutzerdefinierte Modelle für Methoden erstellen und injizieren, die benutzerdefinierte Mappings verwenden.

Der einfachste Weg, alles miteinander zu verdrahten, besteht darin, im AWS-SAM-Template (template.yaml) einen generischen SpringDelegatingLambdaContainerHandler aus dem Artefakt aws-serverless-java-container-springboot3 zu definieren und zusätzlich die Hauptklasse unserer Spring-Boot-Anwendung (die mit @SpringBootApplication annotierte Klasse) als Umgebungsvariable MAIN_CLASS mitzugeben (Listing 2).

Listing 2

Handler: com.amazonaws.serverless.proxy.spring.SpringDelegatingLambdaContainerHandler 
Environment:
  Variables:
    MAIN_CLASS: com.amazonaws.serverless.sample.springboot3.Application

SpringDelegatingLambdaContainerHandler spielt die Rolle des Proxys, empfängt alle Anfragen und leitet sie an die richtige Methode unseres Spring Boot Controllers (ProductController-Klasse) weiter.

Eine andere Möglichkeit, alles miteinander zu verbinden, ist die Implementierung unseres eigenen Request Stream Handlers (StreamLambdaHandler-Klasse), der die Schnittstelle com.amazonaws.services.lambda.runtime.RequestStreamHandler implementiert, um sie im AWS-SAM-Template (template.yaml) zu definieren (Listing 3).

Listing 3

Globals:
  Function:
    Handler: software.amazonaws.example.product.handler.StreamLambdaHandler::handleRequest

StreamLambdaHandler als benutzerdefinierter generischer Proxy empfängt dann alle Anfragen und leitet sie an die richtige Methode unseres Spring-Boot-Controllers (ProductController-Klasse) weiter. Im StreamLambdaHandler instanziieren wir zunächst den SpringBootLambdaContainerHandler:

SpringBootLambdaContainerHandler<AwsProxyRequest, AwsProxyResponse> handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(Application.class);

Dabei haben wir Application.class, also die Hauptklasse unserer Spring-Boot-Anwendung (die mit @SpringBootApplication annotierte Klasse) bei der Instanziierung des Handlers als Parameter mitgegeben.

Im folgenden Code, der Teil der StreamLambdaHandler-Klasse ist, wird der Eingabestrom an die vorgesehene Methode des Product Controller des Handlers weitergeleitet:

@Override
  public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) throws IOException {
    handler.proxyStream(inputStream, outputStream, context);
  }

Dieser Ansatz ist vorzuziehen, wenn wir beabsichtigen, dort eigene Logik zu implementieren, wie wir es im nächsten Artikel der Serie mit Lambda SnapStart Priming sehen werden.

Somit haben wir uns alle relevanten Teile des Quellcodes der Spring-Boot-3-Anwendung auf AWS Lambda mit AWS Serverless Java Container angesehen und erfahren, wie hier alles zusammenspielt. Jetzt wollen wir uns Kalt- und Warmstartmetriken anschauen und verfolgen, wie man vor allem den Kaltstart optimieren kann.

Messstrategien für Kalt- und Warmstarts

Einen guten Überblick über den Kaltstart in AWS-Serverless-Anwendungen, AWS Lambda SnapStart, Pre- und Post-Snapshot Hooks (basierend auf CRaC) sowie Priming-Techniken bieten mein Artikel im Java Magazin und meine Onlineartikelserie [8], [10]. Auf diesen Konzepten bauen wir im Folgenden auf.

Wir wollen den Kalt- und Warmstart der Lambda-Funktion namens GetProductByIdWithSpringBoot32 (siehe template.yaml für das Mapping), die Produkte anhand einer ID ermittelt, für vier verschiedene Fälle messen:

  1. ohne Aktivierung von AWS (Lambda) SnapStart

  2. mit Aktivierung von AWS (Lambda) SnapStart, aber ohne Anwendung von Priming

  3. mit Aktivierung von AWS (Lambda) SnapStart und Anwendung von Priming einer DynamoDB-Anfrage

  4. mit Aktivierung von AWS (Lambda) SnapStart und mit Anwendung des lokalen Priming/Proxying der ganzen Webanfrage

Jetzt gehen wir alle vier Fälle einzeln durch und schauen, was wir dabei beachten müssen.

Ohne Aktivierung von AWS (Lambda) SnapStart

Wir können den Code im IaC-Template, basierend auf AWS SAM (template.yaml), aus Listing 2 oder 3 dafür übernehmen. In der Defaultvariante wird SnapStart nicht aktiviert.

Mit Aktivierung von AWS (Lambda) SnapStart, aber ohne Anwendung von Priming

Wir können den Code im IaC-Template, basierend auf AWS SAM (template.yaml), aus Listing 2 oder 3 übernehmen, allerdings müssen wir ergänzend SnapStart auf den Lambda-Funktionen aktivieren, wie z. B. in Listing 4.

Listing 4

Globals:
  Function:
    Handler: software.amazonaws.example.product.handler.StreamLambdaHandler::handleRequest
    SnapStart: ApplyOn: PublishedVersions 

Mit Aktivierung von AWS (Lambda) SnapStart und Priming der DynamoDB-Anfrage

IaC, basierend auf AWS SAM (template.yaml) sieht für diesen Fall so aus wie in Listing 5.

Listing 5

Globals:
  Function:
    Handler: software.amazonaws.example.product.handler. StreamLambdaHandlerWithDynamoDBRequestPriming::handleRequest
    SnapStart: ApplyOn: PublishedVersions 

Wir aktivieren SnapStart für Lambda-Funktionen und verwenden die eigens geschriebene Lambda-RequestStreamHandler-Implementierung namens StreamLambdaHandlerWithDynamoDBRequestPriming, die zusätzlich DynamoDB-Anfrage-Priming (basierend auf CRaC) durchführt. Den dafür relevanten Quellcode der Klasse zeigt Listing 6.

Listing 6

public class StreamLambdaHandlerWithDynamoDBRequestPriming implements RequestStreamHandler, Resource {
  private static final ProductDao productDao = new DynamoProductDao();
  ...
  public StreamLambdaHandlerWithDynamoDBRequestPriming () {
    Core.getGlobalContext().register(this);
  }

  ...
 
  @Override
    public void beforeCheckpoint(org.crac.Context<? extends Resource> context) throws Exception {
      productDao.getProduct("0");
    } 
  ...

}

Die Klasse implementiert das Interface org.crac.Resource und registriert sich selbst als CRaC-Ressource im Konstruktor. Das Priming erfolgt in der Methode beforeCheckpoint, in der wir DynamoDB nach dem Produkt mit der ID 0 fragen. Dadurch wird der Großteil des Aufrufs der Lambda-Funktion namens GetProductByIdWithSpringBoot32 (siehe template.yaml fürs Mapping) geprimet.

Dabei sind wir nicht mal am Ergebnis interessiert, sondern mit diesem Aufruf werden alle dafür benötigten Klasse instanziiert und die teure einmalige Initialisierung des HTTP-Clients (Default ist der Apache-HTTP-Client) sowie des Jackson Marshallers (zwecks Konvertierung von Java-Objekten nach JSON und andersrum) vorgenommen. Da das bei aktiviertem SnapStart während der Deployment-Phase der Lambda-Funktion geschieht und bevor der Snapshot erstellt wird, wird der Snapshot all das dann bereits enthalten. Nach der schnellen Snapshot-Widerherstellung während des Lambda-Aufrufs werden wir durch Priming dieser Art im Falle des Kaltstarts viel an Performance gewinnen (siehe untenstehende Messungen).

Mit Aktivierung von AWS (Lambda) SnapStart und Anwendung des lokalen Priming/Proxying der ganzen Webanfrage

IaC, basierend auf AWS SAM (template.yaml) sieht für diesen Fall so aus, wie es Listing 7 zeigt.

Listing 7

Globals:
  Function:
    Handler: software.amazonaws.example.product.handler. StreamLambdaHandlerWithWebRequestPriming::handleRequest
    SnapStart: ApplyOn: PublishedVersions 

Wir aktivieren SnapStart für Lambda-Funktionen und verwenden die eigens geschriebene Lambda-RequestStreamHandler-Implementierung namens StreamLambdaHandlerWithWebRequestPriming, die zusätzlich lokales Priming/Proxying der ganzen Webanfrage (basierend auf CRaC) durchführt. Dabei erstellen wir den JSON-Code, der normalerweise vom Amazon API Gateway verwendet wird, um Lambda-Funktionen (in unserem Fall die Lambda-Funktion, die die Produkte nach ID aus der DynamoDB abfragt) aufzurufen, allerdings geschieht alles lokal ohne Netzwerkkommunikation. Listing 8 zeigt den dafür relevanten Quellcode der Klasse.

Listing 8

public class StreamLambdaHandlerWithWebRequestPriming implements RequestStreamHandler, Resource {

private static SpringBootLambdaContainerHandler<AwsProxyRequest, AwsProxyResponse> handler;
  static {
    try {
      handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(Application.class);
    } catch (ContainerInitializationException e) {
      ...
    }
  }

  ...
  public StreamLambdaHandlerWithWebRequestPriming () {
    Core.getGlobalContext().register(this);
  }

  ...
  
  @Override
  public void beforeCheckpoint(org.crac.Context<? extends Resource> context) throws Exception {
    handler.proxyStream(new ByteArrayInputStream(getAPIGatewayRequest().getBytes(StandardCharsets.UTF_8)), new ByteArrayOutputStream(), new MockLambdaContext());

  }

  ...
  private static AwsProxyRequest getAwsProxyRequest () {
    final AwsProxyRequest awsProxyRequest = new AwsProxyRequest ();
    awsProxyRequest.setHttpMethod("GET");
    awsProxyRequest.setPath("/products/0");
    awsProxyRequest.setResource("/products/{id}");
    awsProxyRequest.setPathParameters(Map.of("id","0"));
    final AwsProxyRequestContext awsProxyRequestContext = new AwsProxyRequestContext();
    final ApiGatewayRequestIdentity apiGatewayRequestIdentity= new ApiGatewayRequestIdentity();
    apiGatewayRequestIdentity.setApiKey("blabla");
    awsProxyRequestContext.setIdentity(apiGatewayRequestIdentity);
    awsProxyRequest.setRequestContext(awsProxyRequestContext);
    
    return awsProxyRequest;
  }
  ...
}

Die Klasse implementiert ebenfalls das Interface org.crac.Resource und registriert sich selbst als CRaC-Ressource im Konstruktor. Die getAwsProxyRequest-Methode erstellt den minimalistischen Web-Request mit Hilfe der AwsProxyRequest-Abstraktion aus dem AWS Java Serverless Core Container. Dabei übergeben wir die HTTP-Methode (GET) und den Pfad (/product/0), um das Produkt mit der ID 0 aus der DynamoDB abzufragen.

Das Priming selbst geschieht in der Methode beforeCheckpoint, in der wir das Ergebnis der getAwsProxyRequest-Methode in ByteArrayInputStream konvertieren und SpringBootLambdaContainerHandler es dann als Proxy nutzt. Dadurch wird die Lambda-Funktion namens GetProductByIdWithSpringBoot32 (siehe template.yaml für das Mapping) intern aufgerufen, die dann auch den DynamoDB-Aufruf (und dessen Priming) tätigt. Diese Art von Priming führt natürlich zu sehr viel Extracode, der durch ein paar Utility-Methoden deutlich vereinfacht werden kann. Daher ist die Entscheidung über die Nutzung des Primings dem Entwickler überlassen.

Was wir damit bezwecken, ist, dass mit diesem Priming alle dafür benötigten Klassen instanziiert werden und das Spring-Boot-3-Programmiermodell (und der Aufruf) mit Hilfe des AWS Serverless Java Containers für Spring Boot 3 in das AWS-Lambda-Programmiermodell (und den Aufruf) übersetzt wird. Außerdem wird die teure einmalige Initialisierung des HTTP-Clients (Default ist der Apache-HTTP-Client) und des Jackson Marshallers (zwecks Konvertierung von Java-Objekten nach JSON und anders herum) vorgenommen. Da das bei aktiviertem SnapStart während der Deployment-Phase der Lambda-Funktion geschieht und bevor der Snapshot erstellt wird, wird der Snapshot all das bereits enthalten. Nach der schnellen Snapshot-Wiederherstellung während des Lambda-Aufrufs werden wir durch Priming dieser Art im Fall des Kaltstarts viel an Performance gewinnen (siehe untenstehende Messungen).

Präsentation der Messergebnisse von Kalt- und Warmstarts

Die Ergebnisse des nachstehenden Experiments basieren auf der Reproduktion von mehr als 100 Kalt- und etwa 100 000 Warmstarts mit der Lambda-Funktion für die Dauer einer Stunde. Dafür habe ich das Lasttesttool hey verwendet, das in der Anwendung CURL stark ähnelt [11]. Ihr könnt aber jedes beliebige Tool verwenden, z. B. Serverless Artillery oder Postman [12].

Dabei weisen wir unserer Lambda-Funktion 1024 MB Speicher zu und nutzen die Java-Kompilierungsoption XX:+TieredCompilation -XX:TieredStopAtLevel=1, die man der Lambda-Funktion mit Hilfe der Umgebungsvariable JAVA_TOOL_OPTIONS mitgeben kann. Das gilt als gute Wahl für die relativ kurzlebigen Lambda-Funktionen (Abb. 3). Alternativ können wir die JAVA_TOOL_OPTIONS-Variable im template.yaml auch weglassen. Dann wird die Defaultkompilierungsoption „tiered compilation“ greifen, die ebenfalls sehr gute Ergebnisse produziert.

kazulkin_spring_lambda_1_3

Abb. 3: Setzen der Umgebungsvariable JAVA_TOOL_OPTIONS mit der Kompilierungsoption einer Lambda-Funktion

Tabelle 1 zeigt die Messergebnisse in Millisekunden. Die Abkürzung C steht für Kaltstart und W für Warmstart. Die Zahlen danach sind Perzentile. Bitte beachtet, dass ihr diese sowohl für euren eigenen Anwendungsfall selbst machen müsst als auch für den exakt gleichen Anwendungsfall (leicht) abweichende Ergebnisse bekommen könnt. Das kann folgende Gründe haben:

  • kleinere (Minor-) Versionsänderung an der von Lambda Amazon Corretto Java 21 verwalteten Laufzeitumgebung

  • Verbesserungen beim Erstellen und Wiederherstellen von Lambda-SnapStart-Snapshots

  • Verbesserungen an der Firecracker VM

  • Auswirkungen des Java-Speichermodells (L- oder RAM-Caches treffen und verfehlen)

Szenario

C50

C75

C90

C99

C99.9

CMax

W50

W75

W90

W99

W99.9

Wmax

kein SnapStart

6359

6461

6664

7417

7424

7425

7,88

8,80

10,49

24,61

1312

1956

SnapStart, kein Priming

1949

2061

2523

2713

2995

2996

8,00

9,08

10,99

26,30

267

1727

SnapStart und DynamoDB

Aufruf-Priming

953

1024

1403

1615

1645

1645

8,00

9,24

11,45

26,44

143

505

SnapStart und lokales Priming/Proxying der Webanfrage

742

791

1168

1333

1385

1386

7,63

8,53

10,16

23,09

123

347

Tabelle 1: Die Messergebnisse

Allein durch die Aktivierung von AWS Lambda SnapStart für die Lambda-Funktion wird deren Kaltstartzeit erheblich reduziert. Durch die zusätzliche Verwendung von DynamoDB-Aufruf-Priming und insbesondere lokalem Priming/Proxying der Webanfrage (ich empfehle jedoch nicht, diese Technik in der Produktion zu verwenden) konnten wir Kaltstarts erreichen, deren Messwerte nur geringfügig höher waren als die der Kaltstarts für die reine Lambda-Funktion ohne die Verwendung von Frameworks [13]. Die Warmstartausführungszeiten sind auch im vertretbaren Rahmen höher, als sie für die reine Lambda-Funktion ohne die Verwendung von Frameworks gemessen wurden [13].

Bitte beachtet auch den Effekt des AWS Snapstart Snapshot Tiered Cache [14]. Das heißt, dass wir im Fall der SnapStart-Aktivierung v. a. die größten Kaltstartwerte bei den ersten Messungen bekommen. Durch Tiered Cache werden die nachfolgenden Kaltstarts niedrigere Werte haben. Da ich in der Tabelle die Kaltstarts der ersten 100 Ausführungen nach dem Veröffentlichen der Lambda-Version gezeigt habe, sind alle Kaltstarts ab ca. der 50. Ausführung im Bereich C50 bis C90 also deutlich niedriger. Für weitere Details zur technischen Umsetzung von AWS SnapStart und dessen Tiered Cache verweise ich euch auf den Vortrag von Mike Danilov: „AWS Lambda Under the Hood“ [15].

Fazit

In diesem Artikel haben wir AWS Serverless Java Container und dessen Architektur angeschaut und erklärt, wie wir eine Spring-3-Anwendung auf AWS Lambda mit der Java-21-Laufzeitumgebung mit Hilfe von AWS Serverless Java Container umsetzen. Dabei haben wir gesehen, dass wir die bestehende Spring-Boot-3-Anwendung weitgehend komplett wiederverwenden können. Eigens geschriebene Lambda-Funktionen benötigen wir vor allem dann zusätzlich, wenn wir den AWS Lambda SnapStart aktivieren und Priming-Techniken nutzen. Außerdem haben wir Kalt- und Warmstarts unserer Lambda-Funktionen gemessen, auch für die Fälle der Optimierungen mit AWS Lambda SnapStart inklusive verschiedener Priming-Techniken, die den Kaltstart der Lambda-Funktion deutlich reduzieren.

Im nächsten Teil der Artikelserie stelle ich euch das AWS-Lambda-Web-Adapter-Tool vor und wir lernen, wie man die Spring-Boot-3-Anwendung basierend auf diesem Tool entwickeln und auf AWS Lambda laufen lassen und optimieren kann.

Mit Version 13 wurde das React-Framework Next um den App-Router erweitert, der einen Paradigmenwechsel in der Arbeit mit Next bedeutet. Wir stellen die Vor- und Nachteile der neuen Routinglösung im Vergleich zum bekannten Page-Router vor.

React-Frameworks können dabei helfen, eine herausragende Developer Experience und gute Laufzeitperformanz zu bieten. Seit Jahren gibt es das Static-Site-Generation-(SSG-)Framework Gatsby [1] und neuerdings auch das Server-side-Rendering-(SSR-)Framework Remix [2]. Diese Rendering-Strategien bieten hervorragende Webmesswerte [3]. Der Spitzenkandidat ist jedoch Next.js [4], [5]. Dieses Framework bietet eine Hybridlösung aus einer Kombination von SSR und SSG. Im Folgenden werfen wir einen Blick auf Next.js und schauen uns die Vor- und Nachteile des Page-Routers und des neuen App-Routers an.

Vorteile des Server-side Rendering

Wofür braucht man ein React-Framework? Was von Haus aus in React besonders gut funktioniert, sind Client-side Rendered (CSR) Single Page Applications (SPAs). Sie bieten ein großartiges Weberlebnis, da den Nutzern ermöglicht wird, schnell zwischen Seiten zu wechseln, ohne darauf warten zu müssen, bis diese vom Server geladen werden.

Der große Nachteil von CSR ist, dass die erste Seite, die von den Nutzern besucht wird, als JavaScript Bundle zum Browser gesendet und erst danach als HTML gerendert wird. Das wirkt sich negativ auf Webmesswerte wie First Contentful Paint (FCP) [6] und Total Blocking Time (TBT) [7] aus.

Beide Messwerte profitieren stark von Server-side Rendering und Static Site Generation. SSR startet auf dem Server, nachdem die erste Anfrage empfangen wurde. Dadurch werden die React-Seiten auf dem Server als HTML gerendert, bevor sie zum Browser geschickt werden. Das ist besonders hilfreich für dynamische öffentliche Seiten (wie z. B. Wetterinformationen), bei denen die HTML-Ausgabe zwischengespeichert und an verschiedene Nutzer ausgeliefert werden kann. So wird bei nachfolgenden Anfragen Rechenzeit beziehungsweise Anfragezeit gespart. SSG rendert statische Seiten, bevor sie deployt werden. Dadurch wird die Rechenzeit für das Rendering der HTML-Ausgabe vom Request-Response-Lebenszyklus komplett entkoppelt. Im Vergleich zu SSR kann das zu einer besseren Time to First Byte [8] führen.

Es ist zu beachten, dass statisch generierte Seiten dynamische Inhalte und Interaktionen bieten können, wenn sie nach dem initialen Laden der HTML-Seite weitere JavaScript-Anfragen vom Browser abschicken. Auf interaktiven dynamischen Seiten kann diese Kombination einen im Vergleich zu SSR schlechtere First Input Delay [9] erzeugen.

Der große Konkurrenzvorteil von Next.js entsteht durch die Fähigkeit, einen hochperformanten hybriden SSR/SSG-Ansatz mit einer intuitiven Schnittstelle anzubieten. Mit dem Einsatz eines auf Dateien basierten Routingsystems als zusätzlicher Schnittstelle vor dem React Router vereinfacht Next.js das Routing, während der Rest der React-Schnittstelle unverändert bleibt und die Entwickler und Entwicklerinnen weiterhin volle Flexibilität haben.

Schwächen von Next.js

Next.js ist klarer Gewinner bei der Marktbeliebtheit (nach der State-of-JavaScript-Metrik „usage“, [10]), doch gibt es einige Dinge zu beachten, bevor man sich für das Framework entscheidet. So ist Next.js stark dafür kritisiert worden [11], dass es schwer zu deployen sei. Das problemlose Deployen funktioniert nur über die Plattform Vercel, eine sofort einsetzbare Hostinglösung von der Firma gleichen Namens. Next.js kann auch für andere Hostinglösungen konfiguriert werden, allerdings ist nicht garantiert, dass alle zukünftigen Features unterstützt werden. Um das gewährleisten zu können, müsste man sich für Vercel entscheiden und sich nach ihren Preisen richten, was eine fehlende Unabhängigkeit bedeutet.

Im Mai 2023 wurde die Version 13.4 von Next.js veröffentlicht. Diese bevorzugt den App-Router als dateienbasiertes Routingsystem. Darin wird die experimentelle use client-Syntax [12] eingeführt, um React Server Components [13] unterstützen zu können. Nachdem diese Features von React noch als experimentell bezeichnet werden, fehlt dafür die Unterstützung vieler React-Bibliotheken. Ein Beispiel ist die React Testing Library, die asynchrone Server Components noch nicht rendern kann [14].

Der App-Router

Obwohl der App-Router aufgrund von Namensänderungen im dateienbasierten Routingsystem grundsätzlich anders als der Page-Router aussieht, wird hier der Fokus auf Server Components gelegt.

Im App-Router wird jede React-Komponente als Server Component behandelt, es sei denn, sie wird mit use client als Client Component markiert. Manche Komponenten, wie zum Beispiel page routes [15], müssen Server Components sein, während andere für sich selbst entscheiden können. Diese technische Einschränkung führt dazu, dass leistungsentscheidende Logik (beispielsweise das Rendering von Layouts) die Möglichkeit hat, von den Leistungsvorteilen der Server Components zu profitieren.

Bevor wir tiefer ins Detail gehen, ist es wichtig, zu verstehen, wo die Grenze zwischen Server und Client Components liegt. Es sei daran erinnert, dass React selbst HTML clientseitig durch CSR rendert. Im Gegensatz dazu führen SSR-Frameworks wie Next.js denselben Rendering-Schritt auf dem Server aus. Diese SSR-Funktionalität gilt sowohl für Server als für Client Components. Der Unterschied liegt darin, dass Client Components im Browser (zum Teil) hydratisiert, während Server Components ohne JavaScript zugeliefert werden. Tabelle 1 zeigt die wichtigsten Unterschiede zwischen den Komponenten.

Server Components

Client Components

Profitieren von Next.js SSR/SSG

Ja

Ja

Senden JS zum Browser

Nein

Ja

Können React State nutzen

Nein

Ja

Können async-Funktionen sein

Ja

Nein

Können <head> ändern

Ja

Nein

Können Abfragedaten lesen

Ja

Nein

Tabelle 1: Server und Client Components im Vergleich (Quelle: [16])

Unterstützung von Bibliotheken

Viele bestehende, ausgereifte Bibliotheken im React-Ökosystem sind auf Client Components ausgelegt und daher für das Unterstützen der Server Components schlecht geeignet. Das zeigt sich in verschiedenen Formen und ist oft von der Laufzeitumgebung abhängig (zum Beispiel im Browser oder in Testumgebungen).

Features im Browser

Versucht man, Bibliotheken mit clientexklusiver Funktionalität in Server Components anzuwenden (neue Komponenten werden standardmäßig als Server Components angesehen), erhält man vom Next.js-Server verwirrende Fehlermeldungen. Listing 1 zeigt ein Beispiel aus der Quickstart-Anleitung von React Hook Form [17]. Versucht man, diese Komponente zu rendern, erfolgt zur Laufzeit die Fehlermeldung aus Abbildung 1.

Listing 1

import { useForm } from "react-hook-form";

export default function Home() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
    ...
sigurdsson_next_1

Abb. 1: UseForm Error

Durch das Hinzufügen einer use client-Anweisung wird die Komponentendatei zu einer Client Component umgewandelt und das Problem gelöst. User können vorgeben, dass React Hook Form die Server-Component-Architektur unterstützt, indem ein Wrapper dafür erstellt wird (Abb. 2 und 3). Danach kann die Komponente zwar weiterhin nicht gerendert werden, aber man erhält eine genauere Fehlermeldung als zuvor, wie in Abbildung 4 zu sehen ist.

sigurdsson_next_2

Abb. 2: React Hook Form

sigurdsson_next_3

Abb. 3: use-form-import.ts

sigurdsson_next_4

Abb. 4: UseForm Import Error

Um dieses Verhalten standardmäßig anbieten zu können, müssten alle Bibliotheksverwalter ihren Client Components und React Hooks explizit die use client-Anweisung hinzufügen, nur um diese Next.js-Architektur unterstützen zu können. Ein Jahr nach der Veröffentlichung des App-Routers sind populäre Bibliotheken wie React Hook Form immer noch im Rückstand. Das könnte ein Zeichen dafür sein, dass die neue Next.js-Architektur von der Open-Source-Community von React noch nicht akzeptiert wird.

Testen

Die render-Funktion der React Testing Library (RTL) kann aktuell keine asynchronen Server Components rendern [18]. Bereits existierende Umgehungen helfen dabei, durch das Verlassen der JSX-Syntax solche Komponenten allein zu rendern. Sie funktionieren aber nicht mit den Server Components, die tiefer in der React-Struktur verschaltet sind. Das Verlassen der JSX-Syntax erschwert auch die Verwendung verschiedener Hilfsfunktionen in RTL (zum Beispiel wrapper [19]).

Mocking

Mock Service Worker (MSW) [20] ist ein beliebter Weg, um von JavaScript-Applikationen ausgehende API-Aufrufe zu mocken. Aufgrund der Unvorhersehbarkeit der internen Laufzeitprozesse von Next.js haben solche prozessübergreifenden Lösungen keine Chance, den gesamten Netzwerkverkehr der Applikation abzufangen und mit dem neuen App-Router nutzbar zu bleiben [21].

Es gibt dennoch realisierbare Alternativen, um von Next.js-Applikationen ausgehenden Netzverkehr auch mit dem App-Router zu mocken. Diese sollten aber eher als Möglichkeiten betrachtet werden, das Problem zu umgehen, nicht als bewährte Industriestandardlösungen. Erfahrungswerte aus der Praxis zeigen, dass json-server [22] dafür eingesetzt werden kann. Diese Bibliothek startet ein REST API, dessen Datenstruktur in einer JSON-Datei definiert wird. In dieser Datei stellen wir unsere Mock-Daten zur Verfügung. Da bei einer Next.js-Applikation zur Laufzeit alle Daten von einem einzigen API-Dienst kommen, kann bei einem Test eine Umgebungsvariable genutzt werden, um jede ausgehende Netzwerkanfrage zum Mock-Server umzuleiten.

Es ist zu beachten, dass die aktuelle Version von json-server (1.x.x-alpha) unter der Fair Source License [23] veröffentlicht wurde, die seine Nutzung auf zwei Benutzer beschränkt. Eine ältere Version (von September 2023) ist aber weiterhin unter der flexibleren MIT-Lizenz verfügbar.

Die Grenze zwischen Client und Server

Nur serialisierbare Daten (z. B. JSON) können von Server Components an Client Components weitergegeben werden [24]. Daher sind solche Muster wie Dependency Injection [25] etwas schwieriger anzuwenden, weil keine Funktionen über die Grenze gegeben werden können. Das Beispiel in Listing 2 und die Fehlermeldung dazu (Abb. 5) zeigen, wie solche Muster zu Problemen führen können. Diese technische Einschränkung existiert auch im Page-Router, allerdings ist sie durch die neue JSX-Struktur der Server Components schwerer zu erkennen.

Listing 2

import { MyProduct } from "~/components/my-product";

export default function Home() {
  const getProductData = (productId: string) => {
    // Do something
  };

  return <MyProduct getProductData={getProductData} />;
}
sigurdsson_next_5

Abb. 5: GetProductData Error

Wrapper-Komponenten

Ein Muster, das in unserer eigenen Codebasis schon zu sehen ist, sind Wrapper-Komponenten anstatt benutzerdefinierter React Hooks. In typischen clientgerenderten Applikationen war die Abwägung zwischen diesen beiden Optionen bisher relativ ausgeglichen. Es folgen einige Vor- und Nachteile:

Wrapper-Komponenten fordern eine explizite Kopplung zwischen Datenabfragen und Rendering (Vorteil) (Listing 3) und erschweren das Benennen der Komponenten wegen Namensüberschneidungen (Nachteil).

Listing 3

import { ProductTeasers } from "./product-teasers";

function ProductTeasersWrapper() {
  const products = getProducts();

  return <ProductTeasers products={products} />;
}

Benutzerdefinierte Hooks extrahieren Logik, ohne den Komponentenbaum zu überladen (Vorteil), und erzeugen eine implizite Verbindung zwischen Datenabfragen und Rendering (Nachteil) (Listing 4, 5 und 6).

Listing 4

// src/app/use-products.js

function useProducts() {
  const products = getProducts();

  return products;
}

Listing 5

// src/app/products/page.jsx

function ProductsPage() {
  // Do we use this hook in the consumer...?
  const products = useProducts();

  return <ProductTeasers products={products} />;
}

Listing 6

// src/app/products/teasers.jsx

function ProductTeasers() {
  // ...or inside the component itself?
  const products = useProducts();

  return products.map((product) => <ProductTeaser product={product} />);
}

Im App-Router wirkt das Wrapper-Komponenten-Muster sehr intuitiv, weil wir Folgendes erreichen wollen:

  1. Datenabfragen auf dem Server

  2. Rendering auf dem Client

Die Benennung ist hier weiterhin ungeschickt, dennoch können wir gleichzeitig eine explizite Kopplung erzeugen und use client nutzen, wo es gebraucht wird. Das Resultat sieht so aus, wie es Listing 7 und 8 zeigen.

Listing 7

// src/app/products/teasers.tsx

// This is our server-side wrapper component.
// It gets a concise name because it's what we want to expose to its consumers.
async function ProductTeasers() {
  const products = await fetchProducts();

  return <ProductTeasersForClient products={products} />;
}

Listing 8

// src/app/products/teasers.client.jsx
"use client";

// Our inner Client-Component gets a longer name to avoid overlap
function ProductTeasersForClient({ products }) {
  return products.map((product) => <ProductTeaser product={product} />);
}

Benutzerdefinierte Hooks im App-Router zu schreiben, erscheint unsauber, weil sie dann zu normalen Funktionen werden, wenn sie für Server Components geschrieben werden (das heißt, sie nutzen keine React Hooks). Wrapper-Komponenten sind für die bisherigen Anwendungsfälle besser geeignet.

Für welchen Router sollte man sich entscheiden?

Der Page-Router ist die Version von Next.js, die viele schon kennen und lieben. Er ist auch der Grund, warum das Framework seit Jahren die erste Wahl für React-Entwickler und -Entwicklerinnen ist. Angeblich möchte das Next.js-Team den Page-Router noch mehrere Jahre unterstützen [26]. Sollte das tatsächlich der Fall sein, wird Next.js weiterhin dank der Kombination von Performanz, Ausgereiftheit und Erlernbarkeit die beste Marktlösung bleiben. Bis sich die Unterstützung für den App-Router verbessert, empfiehlt es sich, den Page-Router für bereits existierende sowie neue Projekte zu verwenden.

Für das aktuelle Projekt haben sich die bisher aufgezeigten Probleme in einem solchen Ausmaß angehäuft, dass man gegen das Framework kämpfen muss, anstatt von ihm unterstützt zu werden. Da die geringen Leistungsvorteile von Server Components nicht spürbar sind, scheint der App-Router die falsche Wahl zu sein. Sollte es das Ziel des Next.js-Teams sein, im React/TypeScript-Umfeld die gleiche Stabilität für den App-Router zu erreichen wie für den Page-Router, könnten sich diese Probleme in den kommenden Monaten und Jahren von selbst lösen. Da Next.js primär von einer gewinnorientierten Firma (Vercel) unterstützt wird, lassen sich solche langfristigen Ziele schwer vorhersagen (und es besteht die Gefahr von Feature Bloat). Eine weiterführende Unterstützung des Page-Routers wäre vorteilhaft. Denn sollte sich das schneller ändern, als vom Next.js-Team versprochen, müsste man sich um die Zukunft des Frameworks insgesamt Sorgen machen.

Zusammenfassung und Fazit

React ist für Client-side Rendering gebaut. Möchte man, dass die eigene React-Applikation bei Webmesswerten gut abschneidet, sind Frameworks für Server-side Rendering und Static Site Generation wie Next.js unerlässlich.

Next.js ist in die Kritik geraten, weil es auf anderen Plattformen als Vercel schwer einzusetzen ist. Die verfrühte Einführung der React Server Components hat dazu geführt, dass einige der ausgereiftesten Bibliotheken von React nicht unterstützt werden.

Die Architektur des App-Routers bringt einige grundlegende Änderungen mit sich, die es der Open-Source-Gemeinschaft erschweren, mit der Tooling-Unterstützung Schritt zu halten.

SSR und SSG sind kritische Bausteine für moderne Frontend-Applikationen. Im JavaScript-Umfeld ist der Wettbewerb zwischen neuen Frameworks und Bibliotheken sehr hart, und die Verlierer laufen Gefahr, plötzlich obsolet zu sein. Sollte der App-Router als Ersatz für den beliebten Page-Router nicht die gleiche Stabilität erreichen, wird die Zukunft von Next.js in Gefahr geraten. Das kann auch negative Folgen für React als marktführende Rendering-Bibliothek haben, weil die nächste Generation solcher Bibliotheken (Svelte [27], Solid [28] usw.) bereits reif ist.