Loslegen mit Lagom

Einführung in Lagom: Das Framework für Microservices und Domain Driven Design

Lutz Hühnken

Im Rahmen unseres Themen-Dossiers Scala wollen wir uns nicht nur mit der Sprache Scala beschäftigen, sondern auch die sogenannte „Reactive Platform“, u.a. bestehend aus Akka, Play, Spark und Lagom beleuchten. Wir beginnen mit dem relativ jungen Microservices-Framework Lagom, dessen Grundzüge uns W-JAX-Speaker Lutz Hühnken im Folgendem vorstellt.

Einführung in das Lagom-Framework

Radikal anders, aber trotzdem einfach – das ist der Spagat, den das neue Open-Source Microservice Framework Lagom zu schaffen versucht. Welche Eigenschaften unterscheiden es von anderen Frameworks, wie leicht ist es zu verwenden – und was bedeutet eigentlich der Name?

scala-iconDossier Scala
Diese Woche dreht sich auf JAXenter alles um die JVM-Sprache Scala.

Microservices und Domain Driven Design

Die Frage nach der Bedeutung des Namens Lagom ist nicht ganz einfach zu beantworten, denn eine wörtliche Übersetzung des schwedischen Begriffs Lagom ist nicht bekannt. Laut Wikipedia [1] ist die Bedeutung: „nicht zu viel, nicht zu wenig – gerade richtig.“ Im Fall des Frameworks ist das nicht als Eigenlob gedacht. Vielmehr ist es ein kritisches Statement zum Begriff Microservices. Statt sich auf das „Micro“ zu konzentrieren und ein stures „je weniger Codezeilen, desto besser“ zu verfolgen, schlägt Lagom vor, sich für die Abgrenzung eines Services am Konzept des „Bounded Context“ aus dem Domain Driven Design zu orientieren. Die konzeptionelle Nähe von Domain Driven Design und Microservices wurde ja bereits in Java Magazin 10.2016 [2] beschrieben – im Lagom-Framework findet sie sich an verschiedenen Stellen wieder.

Loslegen mit Lagom

Der einfachste Start, eine Anwendung mit Lagom zu entwickeln, ist die Verwendung der Maven-Projektvorlage.

$ mvn archetype:generate -DarchetypeGroupId=com.lightbend.lagom \
-DarchetypeArtifactId=maven-archetype-lagom-java \
-DarchetypeVersion=1.1.0

Nachdem die Fragen nach Namenswünschen beantwortet sind und in das neu erstellte Verzeichnis gewechselt wurde, findet sich die folgende Verzeichnisstruktur.

cassandra-config
hello-api
hello-impl
integration-tests
pom.xml
stream-api
stream-impl

 

Wie es sich für Microservices gehört, wurde in der Vorlage nicht nur ein einzelner Service generiert, sondern gleich zwei. Schließlich sind das Zusammenspiel und die Kommunikation zwischen Services mindestens genauso wichtig wie die Implementierung eines einzelnen – und oft die größere Herausforderung. Hier sind es die Services „hello“ und „stream“, deren Implementierung sich auf jeweils zwei Unterprojekte verteilt („api“ und „impl“).

Um die Anwendung zu starten, genügt ein einfaches mvn lagom:runAll .

Nach einigen Downloads sollte an Port 9000 ein REST-Service laufen. Überprüfen lässt sich dies leicht mit einem Kommandozeilentool wie HTTPie [3]:

$ http localhost:9000/api/hello/Lagom
HTTP/1.1 200 OK
Content-Type: text/plainHello, Lagom!

Eine Besonderheit ist, dass alle in der Entwicklung benötigten Komponenten, d.h. die Services des Projekts, eine Service Registry, ein API Gateway und sogar die Datenbank Cassandra (in der Embedded-Version) durch das Maven-Plug-in gestartet werden. Ein lokales Einrichten von Services oder einer Datenbank außerhalb des Projektes ist nicht notwendig. Lagom legt großen Wert darauf, dem Entwickler eine Umgebung zu bieten, die sich interaktiv anfühlt – Projekt auschecken und loslegen. Dazu gehört auch, dass Änderungen an einem der Services nach einem Reload direkt wirksam werden, ohne dass ein build/deploy/restart – Zyklus erforderlich wäre.

Das Services API – typsicher und asynchron

Wie aus der Verzeichnisstruktur ersichtlich, teilt sich jeder Service in Lagom in eine Implementierung („-impl“) und die API-Definition („-api“) auf. Mit letzterer wird die HTTP-Schnittstelle des Services programmatisch definiert, wie Listing 1 zeigt.

public interface HelloService extends Service {

  ServiceCall<NotUsed, String> hello(String id);

  default Descriptor descriptor() {
    return named("hello").withCalls(
        pathCall("/api/hello/:id",  this::hello),
      );
  }
}

Über einen Builder wird eine Service-Beschreibung aufgebaut, in der der Request-Pfad auf einen Methoden-Aufruf gemapped wird.

Dieses Interface ist nicht nur die Vorgabe für die Implementierung. Lagom generiert daraus auch eine passende Client-Library. In anderen Lagom-Services kann dieser per Dependency Injection mit Googles Guice injiziert werden, sodass zum Aufruf dieses Services ein typsicheres Interface zur Verfügung steht. Das händische Konstruieren eines HTML Requests und die direkte Verwendung eines generischen HTTP-Clients können entfallen.

Die Verwendung der Client-Library ist aber kein Zwang, denn das Framework bildet die Methodenaufrufe auf HTTP-Calls ab, die natürlich auch, insbesondere von nicht-Lagom-Services, direkt angespochen werden können [4].

Unsere kleine „hello“-Methode liefert übrigens nicht direkt die Response zurück, sondern einen ServiceCall. Dabei handelt sich um ein funktionales Interface. Das bedeutet, wir erzeugen nicht ein einfaches Objekt, sondern eine Funktion – die Funktion, die beim entsprechenden Request ausgeführt werden soll. Als Typ-Parameter übergeben wir die Typen für den Request (da unser GET-Aufruf keine Daten übermittelt, in diesem Fall „NotUsed“) und die Response (in unserem Fall ein einfacher String).

Die Request-Verarbeitung erfolgt immer asynchron – unsere Funktion muss als Ergebnis eine CompletionStage liefern. Lagom macht also umfangreichen Gebrauch von Java-8-Features. Eine einfache Implementierung sähe dann so aus wie in Listing 2.

public class HelloServiceImpl implements HelloService {
  @Override
  public ServiceCall<NotUsed, String> hello(String id) {
    return request -> {
      CompletableFuture.completedFuture("Hello, " + id);
    };
  }
}

Für einen einfachen GET-Request mag sich der Nutzen der Service-Deskriptoren noch in Grenzen halten. Interessanter wird es, wenn wir Events asynchron zwischen Services hin und her senden wollen. In Lagom können wir dies erreichen, indem wir andere Typ-Parameter für den ServiceCall wählen. Definieren wir unsere Request- und Response-Typen als Source (ein Typ aus der Akka-Stream Bibliothek [5]), wie in Listing 3 gezeigt, richtet das Framework eine Websocket-Verbindung ein.

Hier kann die Service-Abstraktion punkten, da sie die Arbeit mit WebSockets erheblich vereinfacht. Für zukünftige Versionen ist die zusätzliche Unterstützung des „publish/subscribe“ Patterns geplant, sodass Nachrichten auf einen Bus gegeben und von anderen Services abonniert werden können.

public interface StreamService extends Service {

  ServiceCall<Source<String, NotUsed>, Source<String, NotUsed>> stream();

  @Override
  default Descriptor descriptor() {
    return named("stream").withCalls(namedCall("stream", this::stream));
  }
}

Schutzschalter eingebaut

Nehmen wir an, unser Service fragt bei einem anderen Service per HTTP-Request Informationen ab. Dieser antwortet aber nicht in der erwarteten Zeit, dementsprechend kommt es zu einer Zeitüberschreitung. Anfragen an diesen Server sollten nun nicht ständig wiederholt werden. Denn zum einen muten wir unserer Applikation damit unnötige Wartezeit zu: Wenn wir mit sehr hoher Wahrscheinlichkeit keine Antwort bekommen, warum auf ein Timeout warten? Außerdem würden sich Anfragen an den Service aufstauen. Wenn er dann wieder verfügbar ist, wird er so sehr mit ausstehenden Requests bombardiert, dass er gleich wieder in die Knie geht.

Eine bewährte Lösung für dieses Problem ist das Circuit-Breaker-Muster [6]. Ein Circuit Breaker kennt drei Zustände:

  • So lange alles fehlerfrei läuft, ist er geschlossen.
  • Wenn eine definierte Schwelle an Fehlern (Timeouts, Exceptions) erreicht ist, öffnet er sich für einen bestimmten Zeitraum. Weitere Anfragen schlagen dann sofort fehl mit einer „CircuitBreakerException“. Für den Client gibt es keine Wartezeit, und der externe Service bekommt von der Anfrage gar nichts mit.
  • Ist der eingestellte Zeitraum abgelaufen, geht der Circuit Breaker in den Zustand „halb geöffnet“. Jetzt geht zunächst eine Anfrage durch. Ist sie erfolgreich, wird der Circuit Breaker geschlossen – das externe System ist offenbar wieder verfügbar. Schlägt sie fehl, geht es für eine weitere Runde in den Zustand „offen“.

Solche Circuit Breaker sind in die Lagom Service Clients bereits integriert. Die Parameter können per Konfigurationsdatei angepasst werden.

Lagom Persistence

Ein Punkt, in dem sich Lagom sehr deutlich von anderen Microframeworks abhebt, ist die Integration eines Frameworks für Event Sourcing und CQRS.

Für viele ist sicher der „Normalfall“ (noch) die Arbeit mit einer relationalen Datenbank, möglicherweise in Verbindung mit einem ORM-Tool. Auch das ließe sich mit Lagom implementieren, der Nutzer wird aber in eine andere Richtung gelenkt. Der Standard in Lagom ist die Verwendung von „Persistent Entities“ (entsprechen „Aggregate Roots“ im Domain Driven Design). Diesen Persistent Entities werden Nachrichten (Commands) gesendet.

Wie dies sich im Code darstellt, zeigt Listing 4.

public class HelloEntity extends PersistentEntity<HelloCommand, HelloEvent, HelloState> {

  @Override
  public Behavior initialBehavior(Optional<HelloState> snapshotState) {

    /*
     * Das Behavior definiert, wie die Entity auf Kommandos reagiert.
     */
    BehaviorBuilder b = newBehaviorBuilder(
        snapshotState.orElse(new HelloState("Hello", LocalDateTime.now().toString())));

    /*
     * Command handler für UseGreetingMessage.
     */
    b.setCommandHandler(UseGreetingMessage.class, (cmd, ctx) ->
    ctx.thenPersist(new GreetingMessageChanged(cmd.message),
        evt -> ctx.reply(Done.getInstance())));

    /*
     * Event handler für GreetingMessageChanged..
     */
    b.setEventHandler(GreetingMessageChanged.class,
        evt -> new HelloState(evt.message, LocalDateTime.now().toString()));

    return b.build();
  }
}

Unsere recht simple Entity erlaubt es uns, den Begrüßungstext für unseren Service zu ändern. Wir beerben die Oberklasse PersistentEntity, die von uns drei Typ-Parameter erwartet: den Typ der Kommandos, den der Events und den des Zustands. In unserem Fall definieren wir als Kommando eine Klasse UseGreetingMessage, die das Interface HelloCommand implementiert und deren Instanzen unveränderlich sind. Um sich Tipparbeit zu sparen, kann man für seine Kommandos, Events und Zustände auf die Bibliothek Immutables zurückgreifen [7].

Wie unsere Entity auf Kommandos reagiert, wird durch ein Behavior festgelegt. Dieses kann zur Laufzeit geändert werden. So können die Entities auch endliche Automaten implementieren – das Ersetzen des Behaviors durch ein anderes zur Laufzeit entspricht dem Übergang des Automaten in einen anderen Zustand.

Das Framework ermittelt das initiale Verhalten durch den Aufruf von initialBehavior. Hier bemühen wir wieder das Builder-Pattern, um es zu konstruieren.

Wir definieren zunächst einen CommandHandler für unser Kommando. Ist eine Nachricht valide und verlangt eine Veränderung der Entity, setzt also zum Beispiel ein Attribut auf einen neuen Wert, dann erfolgt diese Änderung nicht direkt. Stattdessen wird ein Event erzeugt, gespeichert und ausgegeben. Der EventHandler der Persistent Entity, den wir ebenfalls mit dem Builder dem Behavior hinzugefügt haben, reagiert schließlich auf den Event und führt die eigentliche Zustandsänderung durch.

Ein signifikanter Unterschied zu einem „Update“ in einer relationalen Datenbank ist also, dass der aktuelle Zustand der Persistent Entity gar nicht notwendigerweise gespeichert wird. Dieser wird lediglich im Speicher vorgehalten (Memory Image [8]). Wird es nötig, den Zustand wieder herzustellen, z.B. nach einem Neustart der Anwendung, wird dieser durch erneutes Abspielen der Events rekonstruiert. Die optionale Speicherung des aktuellen Zustands wird in diesem Modell „Snapshot“ genannt und ersetzt nicht die Event-Historie, sondern stellt nur eine „Vorverarbeitung“ dar. Wenn eine Entity in ihrer Lebenszeit Tausende von Zustandsänderungen mitgemacht hat, müssen bei der Wiederherstellung nicht alle Events von Beginn an zurückgespielt werden. Es kann abgekürzt werden, indem man mit dem letzten Snapshot beginnt und nur noch die danach aufgetretenen Events wiederholt.

Die strikten Vorgaben, die Lagom in Bezug auf die Typen und die Struktur des Behavior macht, sollen die Umsetzung dieses „Event Sourcing“ genannten Prinzips für die Entwickler leichter machen. Die Idee ist, dass ich gezwungen werde, für jede Entity ein klares Protokoll zu spezifieren: Welche Kommandos können verarbeitet werden, welche Events werden ausgelöst und welche Werte machen den Zustand meiner Klasse aus?

Clustering inklusive

Die Anzahl der Persistent Entities, die ich verwenden kann, ist übrigens nicht etwa durch den Hauptspeicher eines einzelnen Servers begrenzt. Vielmehr kann jede Lagom-Anwendung ohne weiteres Zutun als verteilte Anwendung betrieben werden. Beim Start einer weiteren Instanz muss ich dieser lediglich die Adresse einer bereits laufenden Instanz mitgeben, dann meldet sich diese dort an und bildet mit den bereits vorhandenen Instanzen ein Cluster.

Die Persistent Entities werden vom Framework verwaltet und automatisch im Cluster verteilt (Cluster Sharding). Werden Knoten dem Cluster hinzugefügt oder entfernt, kümmert sich das Framework um die Neuverteilung der Instanzen. Ebenso wie es Instanzen wiederherstellen kann, die aufgrund längerer Nichtnutzung aus dem Speicher entfernt wurden (Passivierung).

Die eingebaute Möglichkeit, Anwendungszustand auf diese Weise im Speicher zu halten, und dieses auch noch quasi beliebig zu skalieren, ist übrigens nicht für Lagom neu entwickelt worden. Lagom setzt dafür unter der Haube auf das Aktoren-Framework Akka [9]. Dieses wird durchaus in unternehmenskritischen Anwendungen eingesetzt, sodass Befürchtungen ob der Zuverlässigkeit des jungen Frameworks sicher unbegründet sind.

Getrenntes Schreiben und Lesen

Während es in einer SQL-Datenbank einfach ist, das Datenmodell nach beliebigen Informationen zu befragen, stellt sich dies im Event Sourcing als unmöglich dar. Wir können nur über den Primärschlüssel auf unsere Entity zugreifen und diese nach dem Zustand fragen. Da wir nur ein Event Log haben, und kein relationales Datenmodell, sind Queries über sekundäre Indizes ausgeschlossen.

Um diese dennoch zu ermöglichen, wird die CQRS-Architektur (Command Query Responsibility Segregation, Lesetipp: A CQRS Journey [10]) angewandt. Grundprinzip hier ist, dass für das Lesen und Schreiben jeweils verschiedene Datenmodelle verwendet werden.

In unserem Fall heißt es, dass unser Event Log die Schreiben-Seite darstellt. Es kann zur Rekonstruktion unserer Entities verwendet werden, wir führen darauf aber keine Queries aus. Stattdessen generieren wir aus den Events auch eine Leseseite. Lagom stellt dazu bereits einen ReadSideProcessor zur Verfügung. Alle Events, die im Zusammenhang mit einer Klasse von PersistentEntities auftreten, werden auch von diesem verarbeitet und verwendet, um die Leseseite aufzubauen. Diese ist für das Lesen optimiert und erlaubt kein direktes Schreiben.

Dieser Architekturansatz hat nicht nur technische Vorteile, da in vielen Anwendungsszenarien die Lese- und Schreibfrequenz sehr unterschiedlich sind und auf diesem Wege unabhängig voneinander skaliert werden können. Es eröffnet auch einige ganz grundsätzlich neue Möglichkeiten. Dadurch, dass die gespeicherten Events niemals gelöscht werden, lassen sich jederzeit neue Strukturen auf der Leseseite, so genannte Projektionen, hinzufügen. Diese können dann durch die historischen Events gefüllt werden und so Aufschlüsse nicht nur über die Zukunft, sondern auch über die Vergangenheit geben.

CQRS erlaubt auch die Verwendung von verschiedenen, an den Use Case angepassten Technologien auf der Leseseite. So ist grundsätzlich denkbar, wenn auch von Lagom noch nicht unterstützt, dass man eine SQL-Leseseite aufbaut, um vorhandenes Tooling weiter zu nutzen, aber gleichzeitig eine ElasticSearch-Datenbank für schnelle Suche füttert, und die Events auch noch zur Auswertung an Spark Streaming schickt.

Wichtig ist jedoch zu beachten, dass die Leseseite asynchron, mit Verzögerung aktualisiert wird („eventual Consistency“ zwischen der Schreib- und Leseseite). Strikte Konsistenz gibt es in diesem Modell nur auf der Ebene der PersitentEntity.

Abschließend soll nicht verschwiegen werden, dass es auch möglich ist, Lagom Services ohne Lagom Persistence zu schreiben. Es muss nicht zwangsläufig Event Sourcing verwendet werden, auch die Entwicklung von „stateless“-Services, oder von „CRUD“-Applikationen (Create, Read, Update, Delete) mit einer SQL-Datenbank im Hintergrund sind möglich.

Wer sich aber für Event Sourcing und CQRS interessiert, die im Bereich der skalierbaren, verteilten Systeme eine wichtige Rolle spielen, kann über Lagom einen relativ einfachen Einstieg in das Thema finden.

Unveränderliche Werte – Immutables

Wir oben erwähnt, müssen die einzelnen Kommandos, Events und die Instanzen des Zustandes unveränderlich sein. Unveränderliche Datenstrukturen sind ein wichtiges Konzept aus der funktionalen Programmierung, das auch insbesondere im Bereich der Nebenläufigkeit von Bedeutung ist. Nehmen wir an, eine Methode bekommt eine Liste von Zahlen übergeben. Als Ergebnis liefert sie einen Wert, der sich aus dieser Liste errechnet (vielleicht den Mittelwert der Zahlen dieser Liste). Durch intensives Nachdenken, oder in manchen Fällen vielleicht sogar durch mathematischen Beweis, kann man glaubhaft darlegen, dass die Funktion korrekt ist und für eine gleiche Eingabe auch immer die gleiche Ausgabe liefert.

Wenn es sich aber bei der übergebenen Liste z.B. um eine ArrayList handelt – können wir uns überhaupt sicher sein? Fix ist ja nur die Referenz, die übergeben wird. Aber vielleicht besitzt ein anderer Programmteil, der nebenläufig ausgeführt wird, die selbe Referenz? Und dort werden der Liste einfach noch ein paar Werte hinzugefügt?

In asynchronen Systemen, die auf dem Versenden von Nachrichten beruhen, ist es essentiell, dass eine Nachricht nicht nach dem Versand noch verändert wird. Sich hierfür nur darauf zu verlassen, dass der Entwickler schon aufpassen wird, wäre fahrlässig.

Lagom bedient sich hierfür externer Bibliotheken. Für die Nachrichten bindet es Immutables [7] ein, für Collections pCollections [11]. Wenn ich einer Collection aus dieser Library einen Wert hinzufüge, bleibt die ursprüngliche Collection unverändert, und ich bekomme eine neue Instanz, die den zusätzlichen Wert enthält, zurück.

Deployment

Microservices stellen nicht nur für die Entwicklung, sondern auch für den Betrieb eine Herausforderung dar. In vielen Firmen sind die Deployment-Prozesse noch auf die Installation von .war oder .ear-Dateien auf Application Servern ausgelegt. Microservices laufen jedoch „standalone“, oft gekapselt in (Docker-) Containern und verwaltet von sogenannten Service Orchestration Tools wie Kubernetes oder Docker Swarm.

Auch Lagom setzt eine solche Umgebung voraus. Es ist nicht von einem bestimmten Container-Format wie Docker abhängig, erfordert aber, dass in der Laufzeitumgebung eine Registry zur Verfügung steht, über die andere Services gefunden werden können. Diese muss für den Zugriff eine Implementierung des Lagom ServiceLocator API zu Verfügung stellen.

Derzeit ist dies leider nur für Lightbends kommerzielles, Closed-Source-Produkt ConductR verfügbar. Die Open-Source Community arbeitet an Implementierungen für Kubernetes [12] und Consul [13]. Alternativ kann ein auf statischer Konfiguration basierender ServiceLocator verwendet werden, wovon für den Produktiveinsatz aber abgeraten wird.

Fazit

Lagom geht einen interessanten Weg und ist als Framework sicher außergewöhnlich. In seiner technologischen Basis ist es grundlegend anders: Alles ist asynchron, basiert auf dem Versenden von Nachrichten, persistiert wird per Default mit Event Sourcing. Das bringt enorme Vorteile für die Skalierbarkeit der Services – aber für die allermeisten Entwickler, eingeschlossen aller, die aus dem Java-EE-Bereich kommen, bedeutet es erst einmal ein Umdenken. Ähnlich wie beim Wechsel auf eine andere Programmiersprache liegt die Befürchtung nahe, dass die Produktivität erst einmal in den Keller geht, weil Entwickler auf vieles Vertrautes nicht mehr zurückgreifen können.

Lagom versucht diesen Knick zu vermeiden, indem es den Entwicklern einen klaren Pfad vorgibt. Wenn ich bei der Service-Implementierung und der Persistenz in Lagom „Schema F“ folge, wie die Dokumentation es vorgibt, habe ich ein reaktives System gebaut – komplett Messaging-basiert, Cluster-fähig usw. – vielleicht ohne es zu merken.

Im relativ jungen Bereich der Microservices müssen sich Standards erst noch herausbilden. Es muss sich noch zeigen, welche Frameworks, und damit verbunden welche Konzepte, sich für längere Zeit behaupten können. Im Kontrast zu den alten Bekannten rund um Java EE und Spring bringt Lagom hier frischen Wind und wirft eine ganz andere Architektur in die Waagschale. Wer offen für Neues ist und sich für skalierbare, verteilte Systeme interessiert, findet mit Lagom einen schnellen Einstieg.

Geschrieben von
Lutz Hühnken
Lutz Hühnken
Lutz Huehnken ist Solutions Architect bei Lightbend. Aktuell beschäftigt er sich mit der Entwicklung von Microservices mit Scala, Akka und Lagom. Er tweeted als @lutzhuehnken und blogged unter https://huehnken.de.
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.