Kapitel 4. Die Zerlegung des Monolithen

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Das ultimative Ziel sollte sein, die Lebensqualität der Menschen durch digitale Innovationen zu verbessern.

Pony Ma Huateng

Im Laufe der Geschichte waren die Menschen davon besessen, Ideen und Konzepte in einfache oder zusammengesetzte Teile zu zerlegen.Durch die Kombination von Analyse und Synthese können wir ein höheres Maß an Verständnis erreichen.

Aristoteles nannte die Analytik "die Auflösung jeder Verbindung in die Dinge, aus denen die Synthese gemacht wird. Denn die Analyse ist das Gegenteil der Synthese.Die Synthese ist der Weg von den Prinzipien zu den Dingen, die sich aus den Prinzipien ableiten, und die Analyse ist die Rückkehr vom Ende zu den Prinzipien."

Die Softwareentwicklung folgt einem ähnlichen Ansatz: Ein System wird in seine Bestandteile zerlegt, wobei Inputs, gewünschte Outputs und Detailfunktionen identifiziert werden. Während des analytischen Prozesses der Softwareentwicklung haben wir festgestellt, dass nicht geschäftsspezifische Funktionen immer benötigt werden, um Inputs zu verarbeiten und Outputs zu kommunizieren oder zu erhalten. Das macht es offensichtlich, dass wir von wiederverwendbaren, gut definierten, kontextgebundenen, atomaren Funktionen profitieren können, die gemeinsam genutzt, konsumiert oder miteinander verbunden werden können, um die Softwareentwicklung zu vereinfachen.

Es ist ein lang gehegter Wunsch, dass Entwickler sich in erster Linie auf die Implementierung von Geschäftslogik konzentrieren können, um bestimmte Zwecke zu erfüllen - z. B. die Erfüllung klar definierter Anforderungen eines Kunden/Unternehmens, die Erfüllung der wahrgenommenen Bedürfnisse einer bestimmten Gruppe potenzieller Nutzer oder die Nutzung der Funktionalität für den persönlichen Bedarf (zur Automatisierung von Aufgaben). Zu viel Zeit wird jeden Tag damit verschwendet, eines der am häufigsten neu erfundenen Räder neu zu erfinden: zuverlässigen Boilerplate-Code.

Das Microservices-Muster hat in den letzten Jahren an Bekanntheit und Dynamik gewonnen, weil die versprochenen Vorteile überragend sind. Die Vermeidung bekannter Gegenmuster, die Anwendung bewährter Methoden und das Verständnis der Kernkonzepte und -definitionen sind von entscheidender Bedeutung, um die Vorteile dieses Architekturmusters zu nutzen und gleichzeitig die Nachteile der Einführung zu verringern. Dieses Kapitel befasst sich mit Antipatterns und enthält Codebeispiele für Microservices, die mit beliebten Microservice-Frameworks wie Spring Boot, Micronaut, Quarkus und Helidon entwickelt wurden.

Traditionell liefert eine monolithische Architektur oder setzt einzelne Einheiten oder Systeme ein, die alle Anforderungen aus einer einzigen Anwendung heraus erfüllen, und es lassen sich zwei Konzepte unterscheiden: die monolithische Anwendung und die monolithische Architektur.

Eine monolithische Anwendung hat nur eine einzige Instanz, die für die Ausführung aller für eine bestimmte Funktion erforderlichen Schritte verantwortlich ist. Ein Merkmal einer solchen Anwendung ist ein eindeutiger Ausführungspunkt an der Schnittstelle.

Eine monolithische Architektur bezieht sich auf eine Anwendung, bei der alle Anforderungen aus einer einzigen Quelle stammen und alle Teile als eine Einheit geliefert werden. Die Komponenten können so konzipiert sein, dass die Interaktion mit externen Clients eingeschränkt ist, um den Zugriff auf private Funktionen explizit zu begrenzen. Die Komponenten in einem Monolithen können miteinander verbunden oder voneinander abhängig sein, anstatt lose gekoppelt zu sein. Mit anderen Worten: Von außen oder aus der Perspektive des Benutzers gibt es nur wenig Wissen über die Definitionen, Schnittstellen, Daten und Dienste anderer separater Komponenten.

DieGranularität ist die Aggregationsebene, die eine Komponente anderen externen kooperierenden oder zusammenarbeitenden Teilen der Software offenlegt. Der Grad der Granularität in Software hängt von verschiedenen Faktoren ab, z. B. vom Grad der Vertraulichkeit, der innerhalb einer Reihe von Komponenten gewahrt bleiben muss und anderen Verbrauchern nicht offengelegt oder zugänglich sein darf.

Moderne Softwarearchitekturen konzentrieren sich zunehmend auf die Bereitstellung von Funktionen durch die Bündelung oder Kombination von Softwarekomponenten aus verschiedenen Quellen, was zu einer feineren Granularität im Detail führt oder diese betont. Die Funktionalität, die dann verschiedenen Komponenten, Kunden oder Verbrauchern zur Verfügung steht, ist größer als bei einer monolithischen Anwendung.

Um festzustellen, wie unabhängig oder austauschbar ein Modul ist, sollten wir uns die folgenden Merkmale genau ansehen:

  • Anzahl der Abhängigkeiten

  • Stärke dieser Abhängigkeiten

  • Stabilität der Module, von denen es abhängt

Jede hohe Punktzahl, die den vorherigen Merkmalen zugewiesen wird, sollte eine zweite Überprüfung der Modellierung und Definition des Moduls auslösen.

Cloud Computing

FürCloud Computing gibt es mehrere Definitionen. Peter Mell und Tim Grance definieren es als ein Modell für den allgegenwärtigen, bequemen und bedarfsgerechten Netzzugang zu einem gemeinsamen Pool konfigurierbarer Rechenressourcen (wie Netzwerke, Server, Speicherung, Anwendungen und Dienste), die mit minimalem Verwaltungsaufwand und ohne Interaktion mit dem Dienstanbieter schnell bereitgestellt und freigegeben werden können.

In den letzten Jahren hat das Cloud Computing stark zugenommen.Zum Beispiel stiegen die Ausgaben für Cloud-Infrastrukturdienste im letzten Quartal 2020 um 32% auf 39,9 Mrd. USD. Nach Angaben von Canalys lagen die Gesamtausgaben um mehr als 3 Mrd. USD höher als im Vorquartal und fast 10 Mrd. USD höher als im vierten Quartal 2019.

Es gibt mehrere Anbieter, aber der Marktanteil ist nicht gleichmäßig verteilt. Die drei führenden Anbieter sind Amazon Web Services (AWS), Microsoft Azure und Google Cloud. AWS ist der führende Cloud-Provider mit einem Anteil von 31 % an den Gesamtausgaben im vierten Quartal 2020. Die Wachstumsrate von Azure hat sich beschleunigt und ist um 50 % gestiegen, mit einem Anteil von fast 20 %, während Google Cloud einen Anteil von 7 % am Gesamtmarkt hat.

Die Nutzung von Cloud-Computing-Diensten hinkt hinterher. Cinar Kilcioglu und Aadharsh Kannan berichteten 2017 in den "Proceedings of the 26th International World Wide Web Conference", dass die Nutzung von Cloud-Ressourcen in Rechenzentren eine erhebliche Lücke zwischen den Ressourcen, die Cloud-Kunden zuweisen und bezahlen (Leasing von VMs), und der tatsächlichen Ressourcennutzung (CPU, Speicher usw.) aufweist. Möglicherweise lassen Kunden ihre VMs einfach eingeschaltet, nutzen sie aber nicht wirklich.

Cloud-Dienste werden in verschiedene Kategorien unterteilt, die für unterschiedliche Arten von Datenverarbeitung genutzt werden:

Software as a Service (SaaS)

Der Kunde kann die Anwendungen des Anbieters nutzen, die auf einer Cloud-Infrastruktur laufen. Der Zugriff auf die Anwendungen erfolgt von verschiedenen Client-Geräten aus entweder über eine Thin-Client-Schnittstelle, wie z. B. einen Webbrowser, oder über eine Programmschnittstelle. Der Kunde verwaltet oder kontrolliert nicht die zugrunde liegende Cloud-Infrastruktur, einschließlich Netzwerk, Server, Betriebssysteme, Speicherung oder sogar einzelne Anwendungsfunktionen, mit der möglichen Ausnahme von begrenzten benutzerspezifischen Anwendungskonfigurationseinstellungen.

Plattform als Service (PaaS)

Der Kunde kann in der Cloud-Infrastruktur selbst erstellte oder erworbene Anwendungen bereitstellen, die mit den vom Anbieter unterstützten Programmiersprachen, Bibliotheken, Diensten und Tools erstellt wurden. Der Kunde verwaltet oder kontrolliert nicht die zugrunde liegende Cloud-Infrastruktur, einschließlich Netzwerk, Server, Betriebssysteme oder Speicherung, hat aber die Kontrolle über die bereitgestellten Anwendungen und möglicherweise die Konfigurationseinstellungen für die Anwendungs-Hosting-Umgebung.

Infrastructure as a Service (IaaS)

Der Kunde ist in der Lage, Rechenleistung, Speicherung, Netzwerke und andere grundlegende Rechenressourcen bereitzustellen. Er kann beliebige Software einsetzen und ausführen, darunter Betriebssysteme und Anwendungen. Der Kunde verwaltet oder kontrolliert die zugrunde liegende Cloud-Infrastruktur nicht, hat aber die Kontrolle über Betriebssysteme, Speicherung und eingesetzte Anwendungen sowie möglicherweise eine begrenzte Kontrolle über ausgewählte Netzwerkkomponenten.

Microservices

Der Begriff Microservice ist nicht neu. Peter Rodgers führte den Begriff 2005 ein, als er sich für die Idee von Software als Micro-Web-Services einsetzte. Die Microservice-Architektur - eineWeiterentwicklung der serviceorientierten Architektur (SOA) - ordnet eine Anwendung als eine Sammlung von relativ leichtgewichtigen, modularen Diensten an. Technisch gesehen ist Microservice eine Spezialisierung eines Implementierungsansatzes für SOA.

Microservices sind kleine und lose gekoppelte Komponenten.Im Gegensatz zu Monolithen können sie unabhängig voneinander eingesetzt, skaliert und getestet werden, haben eine einzige Verantwortung, sind kontextgebunden, autonom und dezentralisiert. Sie basieren in der Regel auf Geschäftsfunktionen, sind einfach zu verstehen und können mit verschiedenen Technologiepaketen entwickelt werden.

Wie klein sollte ein Microservice sein? Er sollte so klein sein, dass er kleine, in sich geschlossene und strikt durchgesetzte Atome von Funktionen ermöglicht, die je nach Geschäftsanforderungen nebeneinander bestehen, sich weiterentwickeln oder die vorherigen ersetzen können.

Jede Komponente oder jeder Dienst hat wenig oder gar keine Kenntnis von den Definitionen der anderen separaten Komponenten, und die gesamte Interaktion mit einem Dienst erfolgt über seine API, die seine Implementierungsdetails kapselt. Der Nachrichtenaustausch zwischen diesen Microservices erfolgt über einfache Protokolle und ist normalerweise nicht datenintensiv.

Antipatterns

Das Microservice-Muster führt zu erheblicher Komplexität und ist nicht in allen Situationen ideal.Das System besteht aus vielen Teilen, die unabhängig voneinander arbeiten, und seine Beschaffenheit macht es schwieriger vorherzusagen, wie es in der realen Welt funktionieren wird.

Diese erhöhte Komplexität ist vor allem auf die (potenziell) tausenden von Microservices zurückzuführen, die asynchron im verteilten Computernetzwerk laufen. Bedenke, dass Programme, die schwer zu verstehen sind, auch schwer zu schreiben, zu ändern, zu testen und zu messen sind. All diese Bedenken erhöhen den Zeitaufwand, den Teams für das Verstehen, Diskutieren, Verfolgen und Testen von Schnittstellen und Nachrichtenformaten aufwenden müssen.

Zu diesem Thema gibt es mehrere Bücher, Artikel und Abhandlungen. Ich empfehle einen Besuch auf Microservices.io, den Bericht Microservices AntiPatterns and Pitfalls von Mark Richards (O'Reilly) und "On the Definition of Microservice Bad Smells" von Davide Taibi und Valentina Lenarduzz (veröffentlicht in IEEE Software im Jahr 2018).

Zu den häufigsten Antipatterns gehören die folgenden:

API-Versionierung(statische Vertragsfalle)

APIs müssen semantisch versioniert sein, damit die Dienste wissen, ob sie mit der richtigen Version des Dienstes kommunizieren oder ob sie ihre Kommunikation an einen neuen Vertrag anpassen müssen.

Unangemessener Dienst Privatsphäre Interdependenz

Der Microservice benötigt private Daten von anderen Diensten, anstatt mit seinen eigenen Daten umzugehen, ein Problem, das in der Regel mit einem Datenmodellierungsproblem zusammenhängt. Eine Lösung, die man in Betracht ziehen kann, ist die Zusammenlegung der Microservices.

Mehrzweck-Megaservice

Mehrere Geschäftsfunktionen werden in demselben Dienst implementiert.

Loggen

Fehler und Microservice-Informationen sind in jedem Microservice-Container versteckt. Die Einführung eines verteilten Protokollierungssystems sollte eine Priorität sein, da Probleme in allen Phasen des Software-Lebenszyklus auftreten.

Komplexe dienstübergreifende oder zirkuläre Abhängigkeiten

Eine zirkuläre Dienstbeziehung ist definiert als eine Beziehung zwischen zwei oder mehr Diensten, die voneinander abhängig sind.Zirkuläre Abhängigkeiten können die Fähigkeit von Diensten beeinträchtigen, unabhängig zu skalieren oder einzusetzen, sowie das Prinzip der azyklischen Abhängigkeiten (ADP) verletzen.

Fehlendes API-Gateway

Wenn Microservices direkt miteinander kommunizieren oder wenn die Servicekonsumenten direkt mit jedem Microservice kommunizieren, steigt die Komplexität und die Wartung des Systems nimmt ab. Die bewährte Methode ist in diesem Fall die Verwendung eines API-Gateways.

Ein API-Gateway nimmt alle API-Aufrufe von Clients entgegen und leitet sie dann durch Request-Routing, Komposition und Protokollübersetzung an den entsprechenden Microservice weiter. Das Gateway bearbeitet die Anfrage in der Regel, indem es mehrere Microservices aufruft und die Ergebnisse zusammenfasst, um die beste Route zu ermitteln. Es ist auch in der Lage, zwischen Webprotokollen und webfreundlichen Protokollen für den internen Gebrauch zu übersetzen.

Eine Anwendung kann ein API-Gateway nutzen, um mobilen Kunden einen einzigen Endpunkt zur Verfügung zu stellen, über den sie alle Produktdaten mit einer einzigen Anfrage abfragen können. Das API-Gateway konsolidiert verschiedene Dienste, wie Produktinformationen und Bewertungen, und kombiniert und veröffentlicht die Ergebnisse.

Das API-Gateway ist der Gatekeeper für Anwendungen, die auf Daten, Geschäftslogik oder Funktionen (RESTful-APIs oder WebSocket-APIs) zugreifen, die eine Zwei-Wege-Kommunikation in Echtzeit ermöglichen. Das API-Gateway übernimmt in der Regel alle Aufgaben, die mit der Annahme und Verarbeitung von bis zu Hunderttausenden gleichzeitiger API-Aufrufe verbunden sind, einschließlich der Verwaltung des Datenverkehrs, der Unterstützung des Cross-Origin Resource Sharing (CORS), der Autorisierung und Zugriffskontrolle, der Drosselung, der Verwaltung und der Versionskontrolle der API.

Zu viel teilen

Es ist ein schmaler Grat zwischen der gemeinsamen Nutzung ausreichender Funktionen, um sich nicht zu wiederholen, und dem Entstehen eines Wirrwarrs von Abhängigkeiten, das verhindert, dass Änderungen an Diensten voneinander getrennt werden können. Wenn ein gemeinsam genutzter Dienst geändert werden muss, führt die Bewertung der vorgeschlagenen Änderungen an den Schnittstellen schließlich zu einer organisatorischen Aufgabe, an der mehrere Entwicklungsteams beteiligt sind.

An einem bestimmten Punkt muss die Wahl zwischen Redundanz oder der Extraktion von Bibliotheken in einen neuen gemeinsamen Dienst, den verwandte Microservices unabhängig voneinander installieren und entwickeln können, analysiert werden.

DevOps und Microservices

Microservices passen perfekt in das DevOps-Ideal, kleine Teams einzusetzen, um funktionale Änderungen an den Unternehmensdiensten Schritt für Schritt vorzunehmen - die Idee, große Probleme in kleinere Teile zu zerlegen und sie systematisch anzugehen. Um die Reibung zwischen Entwicklung, Testen und Bereitstellung kleinerer unabhängiger Dienste zu verringern, muss eine Reihe von kontinuierlichen Lieferpipelines vorhanden sein, die einen stetigen Fluss dieser Phasen aufrechterhalten.

DevOps ist ein Schlüsselfaktor für den Erfolg dieses Architekturstils. Es sorgt für die notwendigen organisatorischen Veränderungen, um die Koordination zwischen den Teams, die für die einzelnen Komponenten verantwortlich sind, zu minimieren und Hindernisse für eine effektive, wechselseitige Interaktion zwischen Entwicklungs- und Betriebsteams zu beseitigen.

Vorsicht

Ich rate jedem Team dringend davon ab, das Microservices-Muster zu übernehmen, wenn es nicht über eine robuste CI/CD-Infrastruktur verfügt oder die grundlegenden Konzepte von Pipelines kennt.

Microservice Frameworks

Das JVM-Ökosystem ist riesig und bietet eine Vielzahl von Alternativen für einen bestimmten Anwendungsfall. Es gibt Dutzende von Microservice-Frameworks und -Bibliotheken, so dass es schwierig sein kann, einen Gewinner unter den Kandidaten zu ermitteln.

Dennoch haben bestimmte Frameworks aus verschiedenen Gründen an Beliebtheit gewonnen: Erfahrung der Entwickler, Zeit bis zur Marktreife, Erweiterbarkeit, Ressourcenverbrauch (CPU, Speicher), Startgeschwindigkeit, Fehlerbehebung, Dokumentation, Integration von Drittanbietern und mehr. Diese Frameworks - Spring Boot, Micronaut, Quarkus und Helidon - werden in den folgenden Abschnitten behandelt. Beachte, dass einige der Anleitungen zusätzliche Anpassungen an neuere Versionen erfordern können, da sich einige dieser Technologien recht schnell weiterentwickeln. Ich empfehle dringend, die Dokumentation der einzelnen Frameworks zu lesen.

Außerdem benötigen diese Beispiele mindestens Java 11. und das Ausprobieren von Native Image erfordert auch eine Installation von GraalVM. Es gibt viele Möglichkeiten, diese Versionen in deiner Umgebung zu installieren.Ich empfehle, SDKMAN! zu verwenden, um sie zu installieren und zu verwalten. Der Kürze halber konzentriere ich mich nur auf den Produktionscode - ein einziges Framework könnte ein ganzes Buch füllen! Es versteht sich von selbst, dass du dich auch um die Tests kümmern solltest. Das Ziel für jedes Beispiel ist es, einen trivialen "Hello World"-REST-Dienst zu bauen, der einen optionalen Namensparameter entgegennehmen und mit einer Begrüßung antworten kann.

Wenn du noch nicht mit GraalVM gearbeitet hast, ist es ein Dachprojekt für eine Handvoll Technologien, die die folgenden Funktionen ermöglichen:

  • Ein in Java geschriebener Just-in-Time-Compiler (JIT), der Code während der Ausführung kompiliert und interpretierten Code in ausführbaren Code umwandelt. Für die Java-Plattform gibt es eine Handvoll JITs, die meist in einer Kombination aus C und C++ geschrieben wurden. Graal ist der modernste, der in Java geschrieben wurde.

  • Eine virtuelle Maschine namens Substrate VM, die in der Lage ist, gehostete Sprachen wie Python, JavaScript und R auf der JVM so auszuführen, dass die gehostete Sprache von einer engeren Integration mit den Fähigkeiten und Funktionen der JVM profitiert.

  • Native Image, ein Dienstprogramm, das auf der AOT-Kompilierung basiert, die Bytecode in maschinenausführbaren Code umwandelt. Die daraus resultierende Umwandlung erzeugt eine plattformspezifische ausführbare Binärdatei.

Alle vier hier vorgestellten Frameworks unterstützen GraalVM auf die eine oder andere Weise, wobei sie sich hauptsächlich auf GraalVM Native Image stützen, um plattformspezifische Binärdateien zu erstellen und so die Größe der Anwendung und den Speicherverbrauch zu reduzieren. Beachte, dass es einen Kompromiss zwischen der Verwendung des Java-Modus und des GraalVM Native Image-Modus gibt. Letzterer kann Binärdateien mit einem geringeren Speicherbedarf und einer schnelleren Startzeit erzeugen, erfordert aber eine längere Kompilierungszeit; lang laufender Java-Code wird schließlich optimiert (das ist eine der Hauptfunktionen der JVM), während native Binärdateien während der Ausführung nicht optimiert werden können. Auch die Entwicklungserfahrung ist unterschiedlich, da du eventuell zusätzliche Tools zum Debuggen, Überwachen, Messen usw. verwenden musst.

Spring Boot

Spring Boot ist vielleicht der bekannteste unter den vier Kandidaten, da er auf dem Erbe des Spring Frameworks aufbaut. Wenn man Umfragen unter Entwicklern für bare Münze nimmt, haben mehr als 60 % der Java-Entwickler in irgendeiner Form Erfahrung mit Spring-bezogenen Projekten, so dass Spring Boot die beliebteste Wahl ist.

Mit Spring kannst du Anwendungen (oder Microservices, in unserem Fall) zusammenstellen, indem du vorhandene Komponenten zusammensetzt, ihre Konfiguration anpasst und kostengünstigen eigenen Code versprichst, da deine benutzerdefinierte Logik angeblich kleiner ist als die des Frameworks. Der Trick besteht darin, eine vorhandene Komponente zu finden, die angepasst und konfiguriert werden kann, bevor du deine eigene schreibst. Das Spring Boot-Team legt großen Wert darauf, so viele nützliche Integrationen wie nötig hinzuzufügen, von Datenbanktreibern bis hin zu Überwachungsdiensten, Logging, Journaling, Stapelverarbeitung, Berichterstellung und mehr.

Der typische Weg, um ein Spring Boot Projekt zu booten, ist, dass du zum Spring Initializr gehst, die Features auswählst, die du für deine Anwendung benötigst, und dann auf die Schaltfläche Generieren klickst. Diese Aktion erstellt eine ZIP-Datei, die du in deine lokale Umgebung herunterladen kannst, um loszulegen. In Abbildung 4-1 habe ich die Features Web und Spring Native ausgewählt. Das erste Feature fügt Komponenten hinzu, mit denen du Daten über REST-APIs bereitstellen kannst; das zweite erweitert den Build um einen zusätzlichen Paketierungsmechanismus, der Native Images mit Graal erstellen kann.

Wenn du die ZIP-Datei entpackst und den Befehl ./mvnw verify im Stammverzeichnis des Projekts ausführst, hast du einen guten Ausgangspunkt. Du wirst feststellen, dass der Befehl eine Reihe von Abhängigkeiten herunterlädt, wenn du noch nie eine Spring Boot-Anwendung auf deiner Zielumgebung gebaut hast. Das ist ein normales Verhalten von Apache Maven. Diese Abhängigkeiten werden beim nächsten Aufruf eines Maven-Befehls nicht mehr heruntergeladen - es sei denn, die Versionen der Abhängigkeiten werden in der Datei pom.xml aktualisiert.

dtjd 0401
Abbildung 4-1. Feder Initializr

Die Projektstruktur sollte wie folgt aussehen:

.
├── HELP.md
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── example
    │   │           └── demo
    │   │               ├── DemoApplication.java
    │   │               ├── Greeting.java
    │   │               └── GreetingController.java
    │   └── resources
    │       ├── application.properties
    │       ├── static
    │       └── templates
    └── test
        └── java

Für unsere aktuelle Aufgabe benötigen wir zwei zusätzliche Quellen, die nicht von der Spring Initializr Website erstellt wurden: Greeting.java und GreetingController.java. Diese beiden Dateien kannst du mit einem Texteditor oder einer IDE deiner Wahl erstellen. Die erste Datei, Greeting.java, definiert ein Datenobjekt, das verwendet wird, um Inhalte als JavaScript Object Notation (JSON) darzustellen, ein typisches Format, um Daten über REST zu veröffentlichen. Weitere Formate werden ebenfalls unterstützt, aber JSON-Unterstützung wird ohne zusätzliche Abhängigkeiten mitgeliefert. Diese Datei sollte wie folgt aussehen:

package com.example.demo;

public class Greeting {
    private final String content;

    public Greeting(String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }
}

Es gibt nichts Besonderes an diesem Datenhalter, außer dass er unveränderlich ist. Je nach Anwendungsfall möchtest du vielleicht zu einer veränderbaren Implementierung wechseln, aber für den Moment reicht das aus. Als Nächstes kommt der REST-Endpunkt selbst, der als GET -Aufruf auf einem /greeting-Pfad definiert ist. Spring Boot bevorzugt den Controller-Stereotyp für diese Art von Komponente, was zweifellos an die Zeiten erinnert, als Spring MVC (ja, das ist Model-View-Controller) die bevorzugte Option für die Erstellung von Webanwendungen war. Du kannst auch einen anderen Dateinamen verwenden, aber die Annotation der Komponente muss unverändert bleiben:

package com.example.demo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetingController {
    private static final String template = "Hello, %s!";

    @GetMapping("/greeting")
    public Greeting greeting(@RequestParam(value = "name",
        defaultValue = "World") String name) {
        return new Greeting(String.format(template, name));
    }
}

Der Controller kann einen name -Parameter als Eingabe annehmen und verwendet den Wert World, wenn dieser Parameter nicht übergeben wird. Beachte, dass der Rückgabetyp der gemappten Methode ein einfacher Java-Typ ist; es ist der Datentyp, den wir im vorherigen Schritt definiert haben. Spring Boot wandelt die Daten automatisch von und nach JSON um, basierend auf den Annotationen, die auf den Controller und seine Methoden angewendet werden, sowie auf sinnvollen Voreinstellungen. Wenn wir den Code so belassen, wie er ist, wird der Rückgabewert der Methode greeting() automatisch in einen JSON-Payload umgewandelt. Das ist die Stärke der Entwicklererfahrung von Spring Boot, die sich auf Vorgaben und vordefinierte Konfigurationen stützt, die bei Bedarf angepasst werden können.

Du kannst die Anwendung mit ausführen, indem du entweder den Befehl /.mvnw spring-boot:run aufrufst, der die Anwendung als Teil des Build-Prozesses ausführt, oder indem du die JAR-Datei der Anwendung generierst und sie manuell ausführst, d.h. ./mvnw package gefolgt von java -jar target/demo-0.0.1.SNAPSHOT.jar. In beiden Fällen wird ein eingebetteter Webserver gestartet, der den Port 8080 abhört; der Pfad /greeting wird einer Instanz von GreetingController zugeordnet. Jetzt müssen nur noch ein paar Abfragen gestellt werden, z.B. die folgende:

// using the default name parameter
$ curl http://localhost:8080/greeting
{"content":"Hello, World!"}

// using an explicit value for the name parameter
$ curl http://localhost:8080/greeting?name=Microservices
{"content":"Hello, Microservices!"}

Notiere dir die Ausgabe, die die Anwendung während der Ausführung erzeugt. In meiner lokalen Umgebung zeigt sie (im Durchschnitt), dass die JVM 1,6 Sekunden zum Starten braucht, während die Anwendung 600 Millisekunden für die Initialisierung benötigt. Die Größe des erzeugten JAR beträgt etwa 17 MB. Du kannst dir auch Notizen zum CPU- und Speicherverbrauch dieser trivialen Anwendung machen. Seit einiger Zeit wird vorgeschlagen , dass die Verwendung von GraalVM Native Image die Startzeit und die Binärgröße reduzieren kann. Schauen wir uns an, wie wir das mit Spring Boot erreichen können.

Erinnerst du dich daran, dass wir bei der Projekterstellung die Spring Native-Funktion ausgewählt haben? Leider enthält das generierte Projekt in Version 2.5.0 nicht alle erforderlichen Anweisungen in der pom.xml-Datei. Wir müssen ein paar Anpassungen vornehmen. Zunächst einmal benötigt das von spring-boot-maven-plugin erstellte JAR einen Classifier, da sonst das Native Image nicht richtig erstellt werden kann. Das liegt daran, dass das Anwendungs-JAR bereits alle Abhängigkeiten in einem Spring Boot-spezifischen Pfad enthält, der nicht von native-image-maven-plugin verwaltet wird. Die aktualisierte pom.xml-Datei sollte wie folgt aussehen:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
    https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.0</version>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>11</java.version>
        <spring-native.version>0.10.0-SNAPSHOT</spring-native.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.experimental</groupId>
            <artifactId>spring-native</artifactId>
            <version>${spring-native.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <classifier>exec</classifier>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.experimental</groupId>
                <artifactId>spring-aot-maven-plugin</artifactId>
                <version>${spring-native.version}</version>
                <executions>
                    <execution>
                        <id>test-generate</id>
                        <goals>
                            <goal>test-generate</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>generate</id>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
    <repositories>
        <repository>
            <id>spring-release</id>
            <name>Spring release</name>
            <url>https://repo.spring.io/release</url>
        </repository>
    </repositories>
    <pluginRepositories>
        <pluginRepository>
            <id>spring-release</id>
            <name>Spring release</name>
            <url>https://repo.spring.io/release</url>
        </pluginRepository>
    </pluginRepositories>

    <profiles>
        <profile>
            <id>native-image</id>
            <build>
                <plugins>
                    <plugin>
                        <groupId>org.graalvm.nativeimage</groupId>
                        <artifactId>native-image-maven-plugin</artifactId>
                        <version>21.1.0</version>
                        <configuration>
                            <mainClass>
                                com.example.demo.DemoApplication
                            </mainClass>
                        </configuration>
                        <executions>
                            <execution>
                                <goals>
                                    <goal>native-image</goal>
                                </goals>
                                <phase>package</phase>
                            </execution>
                        </executions>
                    </plugin>
                </plugins>
            </build>
        </profile>
    </profiles>
</project>

Ein weiterer Schritt, bevor wir es ausprobieren können: Stelle sicher, dass eine Version von GraalVM als dein aktuelles JDK installiert ist. Die gewählte Version sollte genau mit der Version von native-image-maven-plugin übereinstimmen, die in der pom.xml-Datei zu finden ist. Die ausführbare Datei native-image muss ebenfalls in deinem System installiert sein; das kannst du durch den Aufruf von gu install native-image erreichen. Der Befehl gu wird von der GraalVM-Installation bereitgestellt.

Wenn alle Einstellungen vorgenommen wurden, können wir eine native ausführbare Datei erzeugen, indem wir ./mvnw -Pnative-image package aufrufen. Du wirst feststellen, dass eine Menge Text über den Bildschirm läuft, da neue Abhängigkeiten heruntergeladen werden und vielleicht ein paar Warnungen wegen fehlender Klassen erscheinen - das ist normal. Der Build dauert auch länger als üblich, und hier liegt der Kompromiss dieser Paketlösung: Wir verlängern die Entwicklungszeit, um die Ausführungszeit in der Produktion zu verkürzen. Sobald der Befehl beendet ist, siehst du eine neue Datei com.example.demo.demoapplication im Zielverzeichnis. Das ist die native ausführbare Datei. Führe sie aus.

Ist dir aufgefallen, wie schnell der Startvorgang abläuft? In meiner Umgebung beträgt die durchschnittliche Startzeit 0,06 Sekunden, während die Anwendung 30 Millisekunden braucht, um sich zu initialisieren. Du erinnerst dich vielleicht daran, dass diese Zahlen im Java-Modus 1,6 Sekunden und 600 Millisekunden betrugen. Das ist ein echter Geschwindigkeitsschub! Wirf nun einen Blick auf die Größe der ausführbaren Datei; in meinem Fall beträgt sie etwa 78 MB. Sieht so aus, als ob sich einige Dinge zum Schlechten entwickelt haben - oder doch nicht? Diese ausführbare Datei ist eine einzelne Binärdatei, die alles enthält, was zum Ausführen der Anwendung benötigt wird, während das JAR, das wir zuvor verwendet haben, eine Java-Laufzeitumgebung benötigt. Die Größe einer Java-Laufzeitumgebung liegt in der Regel im Bereich von 200 MB und besteht aus mehreren Dateien und Verzeichnissen. Natürlich können kleinere Java-Laufzeiten mit jlink erstellt werden, was dann einen weiteren Schritt während des Erstellungsprozesses bedeutet. Es gibt nichts umsonst.

Hören wir vorerst mit Spring Boot auf, denn es gibt noch viel mehr als das, was wir hier gezeigt haben. Weiter zum nächsten Framework.

Mikronaut

Micronaut entstand 2017 als Neuentwicklung des Grails-Frameworks, aber mit einem modernen Look. Grails ist einer der wenigen erfolgreichen "Klone" des Ruby on Rails (RoR)-Frameworks, der die Programmiersprache Groovy nutzt. Grails machte einige Jahre lang von sich reden, bis der Aufstieg von Spring Boot es aus dem Rampenlicht verdrängte, was das Grails-Team dazu veranlasste, nach Alternativen zu suchen, was in Micronaut resultierte. Oberflächlich betrachtet bietet Micronaut ein ähnliches Benutzererlebnis wie Spring Boot, da es Entwicklern ebenfalls ermöglicht, Anwendungen auf der Grundlage bestehender Komponenten und sinnvoller Vorgaben zusammenzustellen.

Eines der wichtigsten Unterscheidungsmerkmale von Micronaut ist die Verwendung von Compile-Time Dependency Injection für das Assemblieren der Anwendung im Gegensatz zu Runtime Dependency Injection, der bisher bevorzugten Methode für das Assemblieren von Anwendungen mit Spring Boot. Durch diese scheinbar triviale Änderung tauscht Micronaut ein wenig Entwicklungszeit gegen einen Geschwindigkeitsschub zur Laufzeit, da die Anwendung weniger Zeit mit dem Bootstrapping verbringt; dies kann auch zu einem geringeren Speicherverbrauch und einer geringeren Abhängigkeit von Java Reflection führen, die in der Vergangenheit langsamer war als direkte Methodenaufrufe.

Es gibt eine Handvoll Möglichkeiten, ein Micronaut-Projekt zu booten, aber die bevorzugte ist, zu Micronaut Launch zu navigieren und die Einstellungen und Funktionen auszuwählen, die dem Projekt hinzugefügt werden sollen. Der Standard-Anwendungstyp definiert die Mindesteinstellungen, um eine REST-basierte Anwendung zu erstellen, wie die, die wir in ein paar Minuten durchgehen werden. Wenn du mit deiner Auswahl zufrieden bist, klicke auf die Schaltfläche Projekt generieren, wie in Abbildung 4-2 gezeigt, was zu einer ZIP-Datei führt, die du auf deine lokale Entwicklungsumgebung herunterladen kannst.

dtjd 0402
Abbildung 4-2. Micronaut Start

Ähnlich wie bei Spring Boot sorgen das Entpacken der ZIP-Datei und das Ausführen des Befehls ./mvnw verify im Stammverzeichnis des Projekts für einen soliden Ausgangspunkt. Dieser Befehlsaufruf lädt Plug-ins und Abhängigkeiten nach Bedarf herunter; der Build sollte nach ein paar Sekunden erfolgreich sein, wenn alles richtig läuft. Die Projektstruktur sollte nach dem Hinzufügen von ein paar zusätzlichen Quelldateien wie folgt aussehen:

.
├── README.md
├── micronaut-cli.yml
├── mvnw
├── mvnw.bat
├── pom.xml
└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           └── demo
        │               ├── Application.java
        │               ├── Greeting.java
        │               └── GreetingController.java
        └── resources
            ├── application.yml
            └── logback.xml

Die Quelldatei Application.java definiert den Einstiegspunkt, den wir vorerst unberührt lassen, da keine Änderungen erforderlich sind. Ebenso lassen wir die Ressourcendatei application.yml unverändert; diese Ressource liefert Konfigurationseigenschaften, die an dieser Stelle nicht geändert werden müssen.

Wir brauchen zwei zusätzliche Quelldateien: das Datenobjekt, das von Greeting.java definiert wird und dessen Aufgabe es ist, eine Nachricht zu enthalten, die an den Konsumenten zurückgeschickt wird, und den eigentlichen REST-Endpunkt, der von GreetingController.java definiert wird. Der Stereotyp des Controllers geht auf die von Grails festgelegten Konventionen zurück, die auch von so ziemlich jedem RoR-Klon befolgt werden. Du kannst den Dateinamen natürlich so ändern, wie er zu deiner Domäne passt, musst aber die @Controller -Annotation beibehalten. Der Quellcode für das Datenobjekt sollte so aussehen:

package com.example.demo;

import io.micronaut.core.annotation.Introspected;

@Introspected
public class Greeting {
    private final String content;

    public Greeting(String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }
}

Einmal mehr verlassen wir uns auf ein unveränderliches Design für diese Klasse. Beachte die Verwendung der@Intro⁠spected Annotation, die Micronaut signalisiert, den Typ zur Kompilierzeit zu untersuchen und ihn als Teil der Dependency-Injection-Prozedur aufzunehmen. Normalerweise kann die Annotation weggelassen werden, da Micronaut herausfindet, dass die Klasse benötigt wird. Sie muss jedoch unbedingt verwendet werden, wenn es darum geht, die native ausführbare Datei mit GraalVM Native Image zu erzeugen; andernfalls wird die ausführbare Datei nicht vollständig sein. Die zweite Datei sollte so aussehen:

package com.example.demo;

import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.QueryValue;

@Controller("/")
public class GreetingController {
    private static final String template = "Hello, %s!";

    @Get(uri = "/greeting")
    public Greeting greeting(@QueryValue(value = "name",
        defaultValue = "World") String name) {
        return new Greeting(String.format(template, name));
    }
}

Wir können sehen, dass der Controller einen einzelnen Endpunkt definiert, der auf /greeting abgebildet wird, einen optionalen Parameter namens name entgegennimmt und eine Instanz des Datenobjekts zurückgibt. Standardmäßig wird Micronaut den Rückgabewert als JSON marshalieren, so dass keine zusätzliche Konfiguration erforderlich ist. Die Anwendung kann auf verschiedene Weise gestartet werden. Entweder rufst du ./mvnw mn:run auf, wodurch die Anwendung als Teil des Build-Prozesses ausgeführt wird, oder du rufst ./mvnw package auf, wodurch eine demo-0.1.jar im Zielverzeichnis erstellt wird, die auf herkömmliche Weise gestartet werden kann, d. h. mit java -jar target/demo-0.1.jar. Wenn du ein paar Abfragen an den REST-Endpunkt stellst, erhältst du eine ähnliche Ausgabe wie diese:

// using the default name parameter
$ curl http://localhost:8080/greeting
{"content":"Hello, World!"}

// using an explicit value for the name parameter
$ curl http://localhost:8080/greeting?name=Microservices
{"content":"Hello, Microservices!"}

Beide Befehle starten die Anwendung recht schnell. In meiner lokalen Umgebung ist die Anwendung im Durchschnitt nach 500 Millisekunden bereit, Anfragen zu verarbeiten, also dreimal so schnell wie Spring Boot bei gleichem Verhalten. Auch die Größe der JAR-Datei ist mit insgesamt 14 MB etwas geringer. So beeindruckend diese Zahlen auch sein mögen, können wir einen Geschwindigkeitsschub erzielen, wenn die Anwendung mit GraalVM Native Image in eine native ausführbare Datei umgewandelt wird. Zum Glück ist der Micronaut-Weg mit dieser Art von Setup freundlicher, da alles, was wir benötigen, bereits im generierten Projekt konfiguriert ist. Das war's. Wir müssen die Build-Datei nicht mit zusätzlichen Einstellungen aktualisieren - es ist alles da.

Du benötigst jedoch wie zuvor eine Installation von GraalVM und der ausführbaren Datei native-image. Die Erstellung einer nativen ausführbaren Datei ist so einfach wie der Aufruf von ./mvnw -Dpackaging=native-image package und nach ein paar Minuten sollten wir eine ausführbare Datei mit dem Namen demo (eigentlich ist es die artifactId des Projekts, falls du dich wunderst) im Zielverzeichnis erhalten. Der Start der Anwendung mit der nativen ausführbaren Datei dauert im Durchschnitt 20 Millisekunden, was einen Geschwindigkeitsgewinn von einem Drittel im Vergleich zu Spring Boot bedeutet. Die Größe der ausführbaren Datei beträgt 60 MB, was mit der reduzierten Größe der JAR-Datei zusammenhängt.

Hören wir auf, Micronaut zu erforschen und gehen wir zum nächsten Framework über: Quarkus.

Quarkus

Obwohl Quarkus erst Anfang 2019 angekündigt wurde, hat die Arbeit an schon viel früher begonnen. Quarkus hat viele Ähnlichkeiten mit den beiden Kandidaten, die wir bisher gesehen haben. Es bietet ein großartiges Entwicklungserlebnis auf der Basis von Komponenten, Konventionen statt Konfiguration und Produktivitätswerkzeugen. Mehr noch, Quarkus hat sich entschieden, wie Micronaut auch Compile-Time Dependency Injection zu verwenden, um die gleichen Vorteile zu nutzen, wie z. B. kleinere Binärdateien, schnelleres Starten und weniger Laufzeitzauber. Gleichzeitig bringt Quarkus seine eigene Note und Besonderheit ein. Und was für manche Entwickler vielleicht am wichtigsten ist: Quarkus setzt mehr auf Standards als die anderen beiden Kandidaten. Quarkus implementiert die MicroProfile-Spezifikationen, die von JakartaEE (früher bekannt als JavaEE) stammen, sowie weitere Standards, die unter dem Dach des MicroProfile-Projekts entwickelt wurden.

Du kannst mit Quarkus loslegen, indem du die Seite Quarkus Configure Your Application aufrufst, um Werte zu konfigurieren und eine ZIP-Datei herunterzuladen. Diese Seite ist mit vielen Goodies ausgestattet, darunter viele Erweiterungen, aus denen du wählen kannst, um bestimmte Integrationen wie Datenbanken, REST-Funktionen, Monitoring usw. zu konfigurieren. Die Erweiterung RESTEasy Jackson muss ausgewählt werden, damit Quarkus Werte nahtlos in und aus JSON umwandeln kann. Wenn du auf die Schaltfläche "Generate your application" klickst, solltest du eine Eingabeaufforderung erhalten, um eine ZIP-Datei in deinem lokalen System zu speichern, deren Inhalt so ähnlich aussehen sollte wie hier:

.
├── README.md
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
       ├── docker
          ├── Dockerfile.jvm
          ├── Dockerfile.legacy-jar
          ├── Dockerfile.native
          └── Dockerfile.native-distroless
       ├── java
          └── com
              └── example
                  └── demo
                      ├── Greeting.java
                      └── GreetingResource.java
       └── resources
           ├── META-INF
              └── resources
                  └── index.html
           └── application.properties
    └── test
        └── java

Wir wissen es zu schätzen, dass Quarkus Docker-Konfigurationsdateien standardmäßig hinzufügt, da es für Microservice-Architekturen in der Cloud mit Containern und Kubernetes entwickelt wurde. Im Laufe der Zeit hat sich das Angebot jedoch erweitert, indem zusätzliche Anwendungstypen und Architekturen unterstützt wurden. Die Datei GreetingResource.java wird ebenfalls standardmäßig erstellt und ist eine typische Jakarta RESTful Web Services (JAX-RS) Ressource. Wir müssen einige Anpassungen an dieser Ressource vornehmen, damit sie das Datenobjekt Greeting.java verarbeiten kann. Hier ist die Quelle dafür:

package com.example.demo;

public class Greeting {
    private final String content;

    public Greeting(String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }
}

Der Code ist so ziemlich identisch mit dem, was wir bereits in diesem Kapitel gesehen haben. Es gibt nichts Neues oder Überraschendes an diesem unveränderlichen Datenobjekt. Im Fall der JAX-RS-Ressource sehen die Dinge ähnlich und doch anders aus, denn das Verhalten, das wir anstreben, ist dasselbe wie zuvor, allerdings weisen wir das Framework über JAX-RS-Annotationen an, seine Magie auszuführen. Der Code sieht also so aus:

package com.example.demo;

import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;

@Path("/greeting")
public class GreetingResource {
    private static final String template = "Hello, %s!";

    @GET
    public Greeting greeting(@QueryParam("name")
        @DefaultValue("World") String name) {
        return new Greeting(String.format(template, name));
    }
}

Wenn du mit JAX-RS vertraut bist, sollte dich dieser Code nicht überraschen. Wenn du jedoch nicht mit den JAX-RS-Annotationen vertraut bist, markieren wir hier die Ressource mit dem REST-Pfad, auf den wir reagieren möchten. Außerdem geben wir an, dass die Methode greeting() einen GET -Aufruf verarbeiten wird und dass der Parameter name einen Standardwert hat. Es muss nichts weiter getan werden, um Quarkus anzuweisen, den Rückgabewert in JSON umzuwandeln, da dies standardmäßig geschieht.

Um die Anwendung zu starten, gibt es mehrere Möglichkeiten, den Entwicklermodus als Teil des Builds zu nutzen. Dies ist eine der Funktionen, die Quarkus auszeichnen, denn damit kannst du die Anwendung starten und alle Änderungen, die du vorgenommen hast, automatisch übernehmen, ohne dass du die Anwendung manuell neu starten musst. Du kannst diesen Modus aktivieren, indem du /.mvnw compile quarkus:dev aufrufst. Wenn du Änderungen an den Quelldateien vornimmst, wirst du feststellen, dass der Build die Anwendung automatisch neu kompiliert und lädt.

Du kannst die Anwendung auch mit dem java Interpreter ausführen, wie wir bereits gesehen haben, was zu einem Befehl wie java -jar target/quarkus-app/quarkus-run.jar führt. Beachte, dass wir ein anderes JAR verwenden, obwohl das demo-1.0.0-SNAPSHOT.jar im Zielverzeichnis existiert; der Grund für diese Vorgehensweise ist, dass Quarkus eine eigene Logik anwendet, um den Bootvorgang auch im Java-Modus zu beschleunigen.

Wenn du die Anwendung ausführst, sollte die Startzeit im Durchschnitt 600 Millisekunden betragen, was ziemlich genau dem entspricht, was Micronaut macht. Außerdem liegt die Größe der vollständigen Anwendung im Bereich von 13 MB. Wenn du ein paar GET Anfragen an die Anwendung schickst, ohne und mit einem name Parameter, erhältst du eine ähnliche Ausgabe wie die folgende:

// using the default name parameter
$ curl http://localhost:8080/greeting
{"content":"Hello, World!"}

// using an explicit value for the name parameter
$ curl http://localhost:8080/greeting?name=Microservices
{"content":"Hello, Microservices!"}

Es sollte nicht überraschen, dass Quarkus auch die Erzeugung von nativen ausführbaren Dateien über GraalVM Native Image unterstützt, da es auf Cloud-Umgebungen abzielt, in denen eine geringe Größe der Binärdateien empfohlen wird. Aus diesem Grund wird Quarkus genau wie Micronaut mit Batterien geliefert und erzeugt von Anfang an alles, was du brauchst. Es ist nicht nötig, die Build-Konfiguration zu aktualisieren, um mit nativen ausführbaren Dateien zu beginnen. Wie bei den anderen Beispielen musst du sicherstellen, dass das aktuelle JDK auf eine GraalVM-Distribution verweist und dass die ausführbare Datei native-image in deinem Pfad zu finden ist. Sobald dieser Schritt erledigt ist, musst du die Anwendung nur noch als native ausführbare Datei verpacken, indem du ./mvnw -Pnative package aufrufst. Dadurch wird das Profil native aktiviert, das die Quarkus Build-Tools anweist, die native ausführbare Datei zu erzeugen.

Nach ein paar Minuten sollte der Build eine ausführbare Datei mit dem Namen demo-1.0.0-SNAPSHOT-runner im Zielverzeichnis erzeugt haben. Wenn du diese ausführbare Datei ausführst, zeigt sich, dass die Anwendung im Durchschnitt in 15 Millisekunden startet. Die Größe der ausführbaren Datei liegt bei knapp 47 MB. Damit ist Quarkus das Framework, das im Vergleich zu den anderen Framework-Kandidaten den schnellsten Start und die kleinste Größe der ausführbaren Datei aufweist.

Mit Quarkus sind wir vorerst fertig. Bleibt noch der vierte Kandidat für das Framework: Helidon.

Helidon

Nicht zuletzt ist Helidon ein Framework speziell für die Entwicklung von Microservices in zwei Varianten entwickelt worden: SE und MP. Die MP-Variante steht für MicroProfile und ermöglicht es dir, Anwendungen mit Hilfe von Standards zu erstellen; diese Variante ist eine vollständige Implementierung der MicroProfile-Spezifikationen. Die SE-Variante hingegen implementiert MicroProfile nicht, bietet aber ähnliche Funktionen mit einem anderen Satz von APIs. Wähle eine Variante je nach den APIs, mit denen du interagieren möchtest, und deiner Vorliebe für Standards; in jedem Fall erledigt Helidon die Aufgabe.

Da Helidon MicroProfile implementiert, können wir eine weitere Website verwenden, um ein Helidon-Projekt zu starten.Die MicroProfile Starter Website(Abbildung 4-3) kann verwendet werden, um Projekte für alle unterstützten Implementierungen der MicroProfile-Spezifikation nach Versionen zu erstellen.

dtjd 0403
Abbildung 4-3. MicroProfile Starter

Rufe die Website auf, wähle die MP-Version aus, die dich interessiert, wähle die MP-Implementierung (in unserem Fall Helidon) und passe vielleicht einige der verfügbaren Funktionen an. Klicke dann auf die Schaltfläche Herunterladen, um eine ZIP-Datei herunterzuladen, die das generierte Projekt enthält. Die ZIP-Datei enthält eine Projektstruktur, die der folgenden ähnelt, außer dass ich natürlich bereits die Quellen mit den beiden Dateien aktualisiert habe, die erforderlich sind, damit die Anwendung so funktioniert, wie wir es wollen:

.
├── pom.xml
├── readme.md
└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           └── demo
        │               ├── Greeting.java
        │               └── GreetingResource.java
        └── resources
            ├── META-INF
            │   ├── beans.xml
            │   └── microprofile-config.properties
            ├── WEB
            │   └── index.html
            ├── logging.properties
            └── privateKey.pem

Zufälligerweise sind die Quelldateien Greeting.java und GreetingResource.java identisch mit den Quellen, die wir im Quarkus-Beispiel gesehen haben. Wie ist das möglich? Zum einen, weil der Code definitiv trivial ist, aber auch (und das ist noch wichtiger), weil beide Frameworks auf die Kraft von Standards setzen. Tatsächlich ist die Datei Greeting.java-Datei in allen Frameworks so gut wie identisch - mit Ausnahme von Micronaut, das eine zusätzliche Annotation benötigt, aber nur, wenn du native ausführbare Dateien erzeugen willst; ansonsten ist sie zu 100 % identisch. Wenn du dich entschieden hast, zu diesem Abschnitt zu springen, bevor du die anderen durchsuchst, siehst du hier, wie die Datei Greeting.java aussieht:

package com.example.demo;

import io.helidon.common.Reflected;

@Reflected
public class Greeting {
    private final String content;

    public Greeting(String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }
}

Es ist einfach ein normales unveränderliches Datenobjekt mit einem einzigen Accessor. Es folgt die Datei GreetingResource.java, die die für die Anwendung benötigten REST-Mappings definiert:

package com.example.demo;

import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;

@Path("/greeting")
public class GreetingResource {
    private static final String template = "Hello, %s!";

    @GET
    public Greeting greeting(@QueryParam("name")
        @DefaultValue("World") String name) {
        return new Greeting(String.format(template, name));
    }
}

Wir wissen die Verwendung von JAX-RS-Annotationen zu schätzen, da wir sehen, dass an dieser Stelle kein Bedarf für Helidon-spezifische APIs besteht. Der bevorzugte Weg, um eine Helidon-Anwendung auszuführen, ist, die Binärdateien zu paketieren und sie mit dem java Interpreter auszuführen. Das bedeutet, dass wir (vorerst) ein wenig auf die Integration von Build-Tools verzichten, aber immer noch die Kommandozeile verwenden können, um eine iterative Entwicklung durchzuführen. Der Aufruf von mvn package gefolgt von java -jar/demo.jar kompiliert, paketiert und führt die Anwendung mit einem eingebetteten Webserver aus, der auf Port 8080 lauscht. Wir können eine Reihe von Abfragen an ihn senden, wie zum Beispiel diese:

// using the default name parameter
$ curl http://localhost:8080/greeting
{"content":"Hello, World!"}

// using an explicit value for the name parameter
$ curl http://localhost:8080/greeting?name=Microservices
{"content":"Hello, Microservices!"}

Wenn du dir die Ausgabe ansiehst, in der der Anwendungsprozess läuft, wirst du sehen, dass die Anwendung im Durchschnitt in 2,3 Sekunden startet und damit der langsamste Kandidat ist, den wir bisher gesehen haben, während die Größe der Binärdateien fast 15 MB beträgt und damit im Mittelfeld aller Messungen liegt. Aber wie das Sprichwort sagt, kann man ein Buch nicht nach seinem Einband beurteilen. Helidon bietet mehr Funktionen, die automatisch konfiguriert sind, was die zusätzliche Startzeit und die größere Bereitstellungsgröße erklären würde.

Wenn die Startgeschwindigkeit und die Größe des Deployments ein Problem darstellen, kannst du den Build umkonfigurieren, um die nicht benötigten Funktionen zu entfernen und auf den nativen Ausführungsmodus umzuschalten. Glücklicherweise hat das Helidon-Team auch das GraalVM Native Image übernommen, und jedes Helidon-Projekt, das wir selbst gebootstrapt haben, wird mit der erforderlichen Konfiguration für die Erstellung nativer Binärdateien geliefert. Wenn du dich an die Konventionen hältst, musst du die pom.xml-Datei nicht anpassen. Wenn du den Befehl mvn -Pnative-image package ausführst, findest du im Zielverzeichnis eine ausführbare Binärdatei mit dem Namen demo. Diese Datei wiegt etwa 94 MB und ist damit die größte bisher. Die Startzeit liegt mit durchschnittlich 50 Millisekunden im gleichen Bereich wie bei den vorherigen Frameworks.

Bis jetzt haben wir einen Blick darauf geworfen, was jedes Framework zu bieten hat, von den Basisfunktionen bis hin zur Integration von Build-Tools. Zur Erinnerung: Es gibt mehrere Gründe, sich für ein bestimmtes Framework zu entscheiden. Ich empfehle dir, für jedes relevante Feature/Aspekt, das sich auf deine Entwicklungsanforderungen auswirkt, eine Matrix aufzuschreiben und jeden dieser Punkte bei jedem Kandidaten zu bewerten.

Serverlos

In diesem Kapitel haben wir uns zunächst mit monolithischen Anwendungen und Architekturen befasst, die in der Regel aus Komponenten und Schichten bestehen, die zu einer einzigen, zusammenhängenden Einheit zusammengefasst sind. Änderungen oder Aktualisierungen an einem bestimmten Teil erfordern die Aktualisierung und Bereitstellung des Ganzen. Ein Ausfall an einer bestimmten Stelle kann auch das Ganze zum Einsturz bringen. Dann sind wir zu Microservices übergegangen. Die Zerlegung des Monolithen in kleinere Teile, die einzeln und unabhängig voneinander aktualisiert und bereitgestellt werden können, sollte die oben genannten Probleme lösen, aber Microservices bringen eine Reihe anderer Probleme mit sich.

Früher reichte es aus, den Monolithen in einem Anwendungsserver auf Big Iron zu betreiben, mit einer Handvoll Replikaten und einem Load Balancer. Dieses Setup hat Probleme mit der Skalierbarkeit. Mit dem Microservices-Ansatz können wir das Netz von Diensten je nach Last vergrößern oder verkleinern. Das erhöht die Elastizität, aber jetzt müssen wir mehrere Instanzen koordinieren und Laufzeitumgebungen bereitstellen, Load Balancer sind ein Muss, API-Gateways werden benötigt, die Netzwerklatenz zeigt ihr hässliches Gesicht, und habe ich schon das verteilte Tracing erwähnt? Ja, das sind eine Menge Dinge, die man beachten und verwalten muss. Aber was wäre, wenn du das nicht müsstest? Was wäre, wenn jemand anderes sich um die Infrastruktur, die Überwachung und andere "Kleinigkeiten" kümmern würde, die für den Betrieb von Anwendungen in großem Maßstab erforderlich sind? Hier kommt der serverlose Ansatz ins Spiel: Du konzentrierst dich auf die eigentliche Geschäftslogik und überlässt alles andere dem serverlosen Anbieter.

Wenn du eine Komponente in kleinere Teile zerlegst, sollte dir ein Gedanke durch den Kopf gehen: "Was ist das kleinste wiederverwendbare Stück Code, in das ich diese Komponente verwandeln kann?" Wenn deine Antwort eine Java-Klasse mit einer Handvoll Methoden und vielleicht ein paar injizierten Kollaborateuren/Diensten ist, bist du nah dran, aber noch nicht am Ziel. Das kleinste Stück wiederverwendbaren Codes ist tatsächlich eine einzige Methode. Stell dir einen Microservice vor, der als eine einzige Klasse definiert ist, die die folgenden Schritte durchführt:

  1. Liest die Eingabeargumente und wandelt sie in ein konsumierbares Format um, das für den nächsten Schritt benötigt wird

  2. Führt das eigentliche Verhalten aus, das der Dienst benötigt, z. B. eine Abfrage an eine Datenbank, Indizierung oder Protokollierung

  3. Transformiert die verarbeiteten Daten in ein Ausgabeformat

Jeder dieser Schritte kann in separaten Methoden organisiert sein. Du wirst bald feststellen, dass einige dieser Methoden unverändert oder parametrisiert wiederverwendbar sind. Ein typischer Weg, dies zu lösen, wäre die Bereitstellung eines gemeinsamen Supertyps für alle Microservices. Das schafft eine starke Abhängigkeit zwischen den Typen, und für einige Anwendungsfälle ist das in Ordnung. Aber für andere müssen Aktualisierungen des gemeinsamen Codes so schnell wie möglich und in einer versionierten Weise erfolgen, ohne den laufenden Code zu stören.

Wenn man sich dieses Szenario vor Augen hält und den gemeinsamen Code stattdessen als eine Reihe von Methoden bereitstellt, die unabhängig voneinander aufgerufen werden können und deren Eingaben und Ausgaben so zusammengesetzt sind, dass eine Pipeline von Datentransformationen entsteht, dann kommt man zu dem, was man heute als Funktionen bezeichnet. Angebote wie Function as a Service (FaaS) sind bei Serverless-Anbietern ein gängiges Thema.

Zusammenfassend lässt sich sagen, dass FaaS eine schicke Umschreibung dafür ist, dass du Anwendungen auf der Grundlage der kleinstmöglichen Bereitstellungseinheit zusammenstellst und den Provider alle Infrastrukturdetails für dich erledigen lässt. In den folgenden Abschnitten werden wir eine einfache Funktion erstellen und in der Cloud bereitstellen.

Einrichten

Heutzutage verfügt jeder große Cloud-Provider über ein FaaS-Angebot mit Add-ons, die mit anderen Tools für Monitoring, Logging, Disaster Recovery usw. verknüpft werden können. In diesem Kapitel werden wir uns für AWS Lambda entscheiden, da das Unternehmen der Urheber der FaaS-Idee ist. Außerdem wählen wir Quarkus als Implementierungsframework, da es derzeit die kleinste Bereitstellungsgröße bietet. Sei dir bewusst, dass die hier gezeigte Konfiguration möglicherweise noch angepasst werden muss oder völlig veraltet ist; überprüfe immer die neuesten Versionen der Tools, die zum Erstellen und Ausführen des Codes benötigt werden. Wir verwenden vorerst Quarkus 1.13.7.

Um eine Funktion mit Quarkus und AWS Lambda einzurichten, brauchst du einen AWS-Account, die AWS CLI, die auf deinem System installiert ist, und die AWS Serverless Application Model (SAM) CLI, wenn du lokale Tests durchführen möchtest.

Wenn du das erledigt hast, besteht der nächste Schritt darin, das Projekt zu booten. Hierfür würden wir wie bisher Quarkus verwenden, nur dass ein Funktionsprojekt ein anderes Setup erfordert.Deshalb ist es besser, auf einen Maven-Archetyp umzusteigen:

mvn archetype:generate \
    -DarchetypeGroupId=io.quarkus \
    -DarchetypeArtifactId=quarkus-amazon-lambda-archetype \
    -DarchetypeVersion=1.13.7.Final

Wenn du diesen Befehl im interaktiven Modus aufrufst, werden dir einige Fragen gestellt, z.B. die Koordinaten der Gruppe, des Artefakts, der Version (GAV) für das Projekt und das Basispaket. Für diese Demo nehmen wir diese:

  • groupId: com.example.demo

  • artifactId: Demo

  • version: 1.0-SNAPSHOT (der Standard)

  • package: com.example.demo (wie groupId)

Das Ergebnis ist eine Projektstruktur, die sich zum Bauen, Testen und Bereitstellen eines Quarkus-Projekts als Funktion eignet, die für AWS Lambda bereitgestellt werden kann. Der Archetyp erstellt Build-Dateien für Maven und Gradle, aber wir brauchen letztere vorerst nicht; außerdem werden drei Funktionsklassen erstellt, aber wir brauchen nur eine. Unser Ziel ist eine ähnliche Dateistruktur wie diese:

.
├── payload.json
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── example
    │   │           └── demo
    │   │               ├── GreetingLambda.java
    │   │               ├── InputObject.java
    │   │               ├── OutputObject.java
    │   │               └── ProcessingService.java
    │   └── resources
    │       └── application.properties
    └── test
        ├── java
        │   └── com
        │       └── example
        │           └── demo
        │               └── LambdaHandlerTest.java
        └── resources
            └── application.properties

Der Kern der Funktion besteht darin, Eingaben mit dem Typ InputObject zu erfassen, sie mit dem Typ ProcessingService zu verarbeiten und die Ergebnisse dann in einen anderen Typ (OutputObject) umzuwandeln. Der Typ GreetingLambda fasst alles zusammen. Schauen wir uns zunächst die beiden Eingabe- und Ausgabetypen an - schließlich handelt es sich um einfache Typen, die nur Daten enthalten, ohne jegliche Logik:

package com.example.demo;

public class InputObject {
    private String name;
    private String greeting;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getGreeting() {
        return greeting;
    }

    public void setGreeting(String greeting) {
        this.greeting = greeting;
    }
}

Das Lambda erwartet zwei Eingabewerte: eine Begrüßung und einen Namen. Wir werden gleich sehen, wie sie vom Verarbeitungsdienst umgewandelt werden:

package com.example.demo;

public class OutputObject {
    private String result;
    private String requestId;

    public String getResult() {
        return result;
    }

    public void setResult(String result) {
        this.result = result;
    }

    public String getRequestId() {
        return requestId;
    }

    public void setRequestId(String requestId) {
        this.requestId = requestId;
    }
}

Das Ausgabeobjekt enthält die umgewandelten Daten und einen Verweis auf die requestID. Wir werden dieses Feld verwenden, um zu zeigen, wie wir Daten aus dem laufenden Kontext abrufen können.

Als Nächstes kommt der Verarbeitungsdienst; diese Klasse ist dafür verantwortlich, die Eingaben in Ausgaben umzuwandeln. In unserem Fall verkettet sie die beiden Eingabewerte zu einem einzigen String, wie hier gezeigt:

package com.example.demo;

import javax.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class ProcessingService {
    public OutputObject process(InputObject input) {
        OutputObject output = new OutputObject();
        output.setResult(input.getGreeting() + " " + input.getName());
        return output;
    }
}

Bleibt nur noch ein Blick auf GreetingLambda, den Typ, mit dem die Funktion selbst zusammengesetzt wird. Diese Klasse muss eine bekannte Schnittstelle implementieren, die von Quarkus bereitgestellt wird und deren Abhängigkeit bereits in der pom.xml-Datei konfiguriert sein sollte, die mit dem Archetyp erstellt wurde. Diese Schnittstelle wird mit Eingabe- und Ausgabetypen parametrisiert. Glücklicherweise haben wir diese bereits. Jedes Lambda muss einen eindeutigen Namen haben und kann auf seinen laufenden Kontext zugreifen, wie im Folgenden gezeigt wird:

package com.example.demo;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;

import javax.inject.Inject;
import javax.inject.Named;

@Named("greeting")
public class GreetingLambda
    implements RequestHandler<InputObject, OutputObject> {
    @Inject
    ProcessingService service;

    @Override
    public OutputObject handleRequest(InputObject input, Context context) {
        OutputObject output = service.process(input);
        output.setRequestId(context.getAwsRequestId());
        return output;
    }
}

Das Lambda definiert die Ein- und Ausgabetypen und ruft den Datenverarbeitungsdienst auf. Zur Veranschaulichung zeigt dieses Beispiel die Verwendung von Dependency Injection, aber du könntest den Code reduzieren, indem du das Verhalten von ProcessingService in GreetingLambda verschiebst. Wir können den Code schnell überprüfen, indem wir lokale Tests mit mvn test durchführen, oder, wenn du es vorziehst, mit mvn verify, da dies auch die Funktion verpackt.

Beachte, dass zusätzliche Dateien in das Zielverzeichnis gelegt werden, wenn die Funktion gepackt wird, insbesondere ein Skript namens manage.sh, das sich auf das AWS CLI-Tool verlässt, um die Funktion am Zielort, der mit deinem AWS-Konto verbunden ist, zu erstellen, zu aktualisieren und zu löschen. Zur Unterstützung dieser Vorgänge sind zusätzliche Dateien erforderlich:

funktion.zip

Die Deployment-Datei, die die binären Bits enthält

sam.jvm.yaml

Lokaler Test mit AWS SAM CLI (Java-Modus)

sam.native.yaml

Lokaler Test mit AWS SAM CLI (nativer Modus)

Der nächste Schritt erfordert, dass du eine Ausführungsrolle konfiguriert hast, wofür du am besten den AWS Lambda Developer Guide zu Rate ziehst, falls das Verfahren aktualisiert wurde. Der Leitfaden zeigt dir, wie du die AWS CLI konfigurierst (falls du das noch nicht getan hast) und eine Ausführungsrolle erstellst, die als Umgebungsvariable zu deiner laufenden Shell hinzugefügt werden muss. Zum Beispiel:

LAMBDA_ROLE_ARN="arn:aws:iam::1234567890:role/lambda-ex"

In diesem Fall steht 1234567890 für deine AWS-Konto-ID und lambda-ex ist der Name der von dir gewählten Rolle. Wir können mit der Ausführung der Funktion fortfahren, für die wir zwei Modi (Java, nativ) und zwei Ausführungsumgebungen (lokal, Produktion) haben; wir nehmen zuerst den Java-Modus für beide Umgebungen in Angriff und folgen dann mit dem nativen Modus.

Um die Funktion in einer lokalen Umgebung auszuführen, muss ein Docker-Daemon verwendet werden, der mittlerweile zum Standardwerkzeug eines Entwicklers gehören sollte. Erinnern Sie sich an die zusätzlichen Dateien im Zielverzeichnis? Wir verwenden die Datei sam.jvm.yaml zusammen mit einer weiteren Datei namens payload.json, die vom Archetyp erstellt wurde, als das Projekt gebootet wurde. Sie befindet sich im Stammverzeichnis und ihr Inhalt sollte wie folgt aussehen:

{
  "name": "Bill",
  "greeting": "hello"
}

In dieser Datei werden die Werte für die Eingaben definiert, die von der Funktion akzeptiert werden. Da die Funktion bereits verpackt ist, müssen wir sie nur noch aufrufen, etwa so:

$ sam local invoke --template target/sam.jvm.yaml --event payload.json
Invoking io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler::handleRequest
(java11)
Decompressing /work/demo/target/function.zip
Skip pulling image and use local one:
amazon/aws-sam-cli-emulation-image-java11:rapid-1.24.1.

Mounting /private/var/folders/p_/3h19jd792gq0zr1ckqn9jb0m0000gn/T/tmppesjj0c8 as
/var/task:ro,delegated inside runtime container
START RequestId: 0b8cf3de-6d0a-4e72-bf36-232af46145fa Version: $LATEST
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
[io.quarkus] (main) quarkus-lambda 1.0-SNAPSHOT on
JVM (powered by Quarkus 1.13.7.Final) started in 2.680s.
[io.quarkus] (main) Profile prod activated.
[io.quarkus] (main) Installed features: [amazon-lambda, cdi]
END RequestId: 0b8cf3de-6d0a-4e72-bf36-232af46145fa
REPORT RequestId: 0b8cf3de-6d0a-4e72-bf36-232af46145fa	Init Duration: 1.79 ms
Duration: 3262.01 ms Billed Duration: 3300 ms
Memory Size: 256 MB	Max Memory Used: 256 MB
{"result":"hello Bill","requestId":"0b8cf3de-6d0a-4e72-bf36-232af46145fa"}

Der Befehl zieht ein Docker-Image, das für die Ausführung der Funktion geeignet ist. Achte auf die gemeldeten Werte, die je nach deiner Einrichtung unterschiedlich sein können. In meiner lokalen Umgebung würde mich diese Funktion 3,3 Sekunden und 256 MB für ihre Ausführung kosten. So kannst du dir einen Eindruck davon verschaffen, wie viel dir berechnet wird, wenn du dein System als Funktionssatz ausführst. Lokal ist jedoch nicht dasselbe wie in der Produktion, also setzen wir die Funktion in der Realität ein. Dazu verwenden wir das Skript manage.sh, indem wir die folgenden Befehle aufrufen:

$ sh target/manage.sh create
$ sh target/manage.sh invoke
Invoking function
++ aws lambda invoke response.txt --cli-binary-format raw-in-base64-out
++ --function-name QuarkusLambda --payload file://payload.json
++ --log-type Tail --query LogResult
++ --output text base64 --decode
START RequestId: df8d19ad-1e94-4bce-a54c-93b8c09361c7 Version: $LATEST
END RequestId: df8d19ad-1e94-4bce-a54c-93b8c09361c7
REPORT RequestId: df8d19ad-1e94-4bce-a54c-93b8c09361c7	Duration: 273.47 ms
Billed Duration: 274 ms	Memory Size: 256 MB
Max Memory Used: 123 MB	Init Duration: 1635.69 ms
{"result":"hello Bill","requestId":"df8d19ad-1e94-4bce-a54c-93b8c09361c7"}

Wie du siehst, sind die abgerechnete Dauer und der Speicherverbrauch gesunken, was gut für unseren Geldbeutel ist, obwohl die Init-Dauer auf 1,6 gestiegen ist, was die Antwort verzögern und die Gesamtausführungszeit im System erhöhen würde. Schauen wir uns an, wie sich diese Zahlen ändern, wenn wir vom Java-Modus in den nativen Modus wechseln. Wie du dich vielleicht erinnerst, kannst du mit Quarkus Projekte als native ausführbare Dateien verpacken, . Aber erinnere dich daran, dass Lambda ausführbare Linux-Dateien benötigt. Wenn du also auf einer Nicht-Linux-Umgebung arbeitest, musst du den Verpackungsbefehl anpassen. Das musst du tun:

# for linux
$ mvn -Pnative package

# for non-linux
$ mvn package -Pnative -Dquarkus.native.container-build=true \
 -Dquarkus.native.container-runtime=docker

Der zweite Befehl ruft den Build innerhalb eines Docker-Containers auf und platziert die erzeugte ausführbare Datei am erwarteten Ort auf deinem System, während der erste Befehl den Build so ausführt, wie er ist. Da die native ausführbare Datei nun vorhanden ist, können wir die neue Funktion sowohl in der lokalen als auch in der Produktionsumgebung ausführen. Sehen wir uns zuerst die lokale Umgebung an:

$ sam local invoke --template target/sam.native.yaml --event payload.json
Invoking not.used.in.provided.runtime (provided)
Decompressing /work/demo/target/function.zip
Skip pulling image and use local one:
amazon/aws-sam-cli-emulation-image-provided:rapid-1.24.1.

Mounting /private/var/folders/p_/3h19jd792gq0zr1ckqn9jb0m0000gn/T/tmp1zgzkuhy as
/var/task:ro,delegated inside runtime container
START RequestId: 27531d6c-461b-45e6-92d3-644db6ec8df4 Version: $LATEST
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
[io.quarkus] (main) quarkus-lambda 1.0-SNAPSHOT native
(powered by Quarkus 1.13.7.Final) started in 0.115s.
[io.quarkus] (main) Profile prod activated.
[io.quarkus] (main) Installed features: [amazon-lambda, cdi]
END RequestId: 27531d6c-461b-45e6-92d3-644db6ec8df4
REPORT RequestId: 27531d6c-461b-45e6-92d3-644db6ec8df4	Init Duration: 0.13 ms
Duration: 218.76 ms	Billed Duration: 300 ms Memory Size: 128 MB
Max Memory Used: 128 MB
{"result":"hello Bill","requestId":"27531d6c-461b-45e6-92d3-644db6ec8df4"}

Die abgerechnete Dauer verringerte sich um eine Größenordnung, von 3300 ms auf nur 300 ms, und der verbrauchte Speicher wurde halbiert; das sieht vielversprechend aus im Vergleich zu seinem Java-Pendant. Werden wir im Produktionsbetrieb bessere Zahlen erhalten? Schauen wir mal:

$ sh target/manage.sh native create
$ sh target/manage.sh native invoke
Invoking function
++ aws lambda invoke response.txt --cli-binary-format raw-in-base64-out
++ --function-name QuarkusLambdaNative
++ --payload file://payload.json --log-type Tail --query LogResult --output text
++ base64 --decode
START RequestId: 19575cd3-3220-405b-afa0-76aa52e7a8b5 Version: $LATEST
END RequestId: 19575cd3-3220-405b-afa0-76aa52e7a8b5
REPORT RequestId: 19575cd3-3220-405b-afa0-76aa52e7a8b5	Duration: 2.55 ms
Billed Duration: 187 ms Memory Size: 256 MB	Max Memory Used: 54 MB
Init Duration: 183.91 ms
{"result":"hello Bill","requestId":"19575cd3-3220-405b-afa0-76aa52e7a8b5"}

Die berechnete Gesamtdauer erhöht sich um 30 %, und der Speicherverbrauch ist weniger als halb so hoch wie zuvor. Der eigentliche Gewinner ist jedoch die Initialisierungszeit, die etwa 10 % der vorherigen Zeit beträgt. Wenn du deine Funktion im nativen Modus ausführst, führt das zu einem schnelleren Start und insgesamt zu besseren Werten.

Nun liegt es an dir, zu entscheiden, mit welcher Kombination von Optionen du die besten Ergebnisse erzielst. Manchmal reicht es aus, im Java-Modus zu bleiben, oder du hast einen Vorteil, wenn du den nativen Modus verwendest. Wie auch immer, Messungen sind der Schlüssel - rate nicht!

Zusammenfassung

Wir haben in diesem Kapitel viel behandelt, angefangen bei einem traditionellen Monolithen, über die Zerlegung in kleinere Teile mit wiederverwendbaren Komponenten, die unabhängig voneinander eingesetzt werden können (Microservices), bis hin zur kleinstmöglichen Einsatzeinheit: einer Funktion. Auf dem Weg dorthin müssen Kompromisse eingegangen werden, denn Microservice-Architekturen sind von Natur aus komplexer, da sie aus mehr beweglichen Teilen bestehen. Die Netzwerklatenz wird zu einem echten Problem und muss entsprechend angegangen werden. Andere Aspekte, wie z. B. Datentransaktionen, werden komplexer, da sie je nach Fall Servicegrenzen überschreiten können. Die Verwendung von Java und nativem Ausführungsmodus führt zu unterschiedlichen Ergebnissen und erfordert eine individuelle Einrichtung, die jeweils ihre eigenen Vor- und Nachteile hat. Meine Empfehlung, liebe Leserin, lieber Leser, ist es, zu evaluieren, zu messen und dann eine Kombination auszuwählen; behalte die Zahlen und Service Level Agreements (SLAs) im Auge, denn es kann sein, dass du im Laufe der Zeit Entscheidungen neu bewerten und Anpassungen vornehmen musst.

Tabelle 4-1 fasst die Messwerte zusammen, die durch das Ausführen der Beispielanwendung sowohl im Java- als auch im Native-Image-Modus, in meiner lokalen Umgebung und im Remote-Modus für jedes der Kandidaten-Frameworks ermittelt wurden. Die Größenspalten zeigen die Größe der Bereitstellungseinheit, während die Zeitspalten die Zeit vom Start bis zur ersten Anfrage darstellen.

Tabelle 4-1. Zusammenfassung der Messungen
Rahmenwerk Java - Größe Java - Zeit Native - Größe Einheimisch - Zeit

Spring Boot

17 MB

2200 ms

78 MB

90 ms

Mikronaut

14 MB

500 ms

60 MB

20 ms

Quarkus

13 MB

600 ms

47 MB

13 ms

Helidon

15 MB

2300 ms

94 MB

50 ms

Wir möchten dich daran erinnern, dass du deine eigenen Messungen durchführen solltest. Änderungen an der Hosting-Umgebung, der JVM-Version und den Einstellungen, der Framework-Version, den Netzwerkbedingungen und anderen Umgebungsmerkmalen können zu unterschiedlichen Ergebnissen führen. Die angegebenen Zahlen sollten mit Vorsicht genossen werden und niemals als verbindliche Werte angesehen werden.

Get DevOps-Tools für Java-Entwickler now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.