#Reactive #Webservices #Lagom

Bei Bibliotheken und Frameworks für Microservices gibt es inzwischen eine recht stattliche Auswahl. Lagom möchte Entwicklern eine weitere Variante bieten: alles Nötige in einer Toolbox, eine Vorauswahl moderner Techniken und Ansätze sowie eine möglichst kurze Zeit von null auf hundert bei der Entwicklung.

Reactive mit Java

Die Macher von Lagom haben in das Framework all das zusammengetragen, was ihrer Meinung nach für den Bau von modernen, verteilten Architekturen nötig ist – quasi als Serviervorschlag, aber ohne dem Nutzer die Möglichkeit zu nehmen, auf andere Techniken auszuweichen und möglichst ohne ihm unnötige Einschränkungen aufzuerlegen. Diese Idee ist auch Urpsrung des Namens: Das schwedische Wort Lagom bedeutet grob übersetzt: „nicht zu viel, aber auch nicht zu wenig“. Lagom
wurde entlang der Ideen des Reactive-Programming-Paradigmas entworfen, was insbesondere bedeutet, dass quasi alle Aufrufe asynchron ablaufen und der eigene Code mit dieser Asynchronität umgehen muss. Wer also bisher noch keine Berührpunkte mit der CompletionStage von Java 8 hatte, hat hier ausgiebig Gelegenheit sie kennen zu lernen.

Apropos Java 8: Auch wenn der Unterbau von Lagom zu großen Teilen in Scala geschrieben ist (und die Firma Lightbend, die hinter Lagom steht, einen Schwerpunkt auf Scala legt), bietet Lagom zunächst nur eine Java-API. Erst mit Version 1.3 ist auch eine Scala-API nachgekommen. Die Unterschiede der beiden APIs sind so gestaltet, dass sie die Sprachgegebenheiten und die Sprachkultur berücksichtigen. Man fühlt sich in beiden Sprachwelten zu Hause.

Die Java-API macht ausgiebig von den Sprach- und Bibliotheks-Features von Java 8 gebrauch. Zielgruppe sind momentan ganz klar Java-Entwickler, die entweder eine neue Anwendung auf einem State-of-the-Art Technik-Stack aufsetzen wollen oder einen bestehenden Monolithen Schritt für Schritt refactorn wollen.

Start!

Ein neues Lagom-Projekt setzt man am einfachsten mit Hilfe des Project-Starters auf: Auf der Seite http://developer.lightbend.com/start/ lässt man sich ein Lagom-Projekt mit den gewünschten Package-Namen generieren. Das Template für Scala benutzt (wie für Scala üblich) sbt als Build-System, beim Java-Template kommt Maven zum Einsatz.

Die Frage nach dem Build-System geht hier tatsächlich über eine reine Geschmacksentscheidung hinaus: Vieles von der „Magie“ von Lagom ist durch Plugins in das Build-System realisiert, sie machen hier weit mehr aus als nur das Herunterladen von Abhängigkeiten und Setzen eines geeigneten Classpaths. Dies wird schnell klar, wenn wir die neu erzeugte Anwendung mittels mvn lagom:runAll starten: Das Kommando startet sämtliche Microservices der Anwendung und zusätzlich Testinstanzen benötigter Dienste, per Default Cassandra und Kafka sowie eine Service-Discovery und ein Eingangs-Gateway, welches Anfragen auf die einzelnen Microservices weiterleitet. Außerdem sorgt das Build-System dafür, dass bei Code-Änderungen die betroffenen Microservices automatisch übersetzt und neu gestartet werden. Dieses Feature erlaubt äußerst bequemes Entwickeln und Testen – jede Codeänderung ist sofort nach einem Reload sichtbar.

Interface und Implementierung

Importiert man das Projekt-Gerüst in seine IDE, sieht man eine Reihe von Unterprojekten: Jeder Microservice in Lagom besteht nämlich aus zwei Teilprojekten, einem Interface-Teil und einer Implementierung. Der Interface-Teil definiert die Schnittstelle zu einem Microservice. Hierzu schreibt man ein Interface zum
Service inklusive der notwendigen Transfer-Objekte. Für einen Konsumenten des Service bringt das den Vorteil, bereits zur Compile-Zeit über Fehler informiert zu werden, die durch Änderung an der Schnittstelle entstehen. Natürlich wird es dadurch nötig, dass Server und Konsumenten eine gemeinsame Abhängigkeit
auf das Interface-Projekt haben, was zunächst wie eine enge Kopplung der Projekte aussieht – ein Umstand, den man mit Microservices eigentlich unbedingt vermeiden will. Das Interface-Projekt sollte jedoch keinerlei Logik enthalten und ist letztlich nicht mehr als eine in Code gegossene Interface-Definition
– API und Datenformate sind ohnehin implizite Abhängigkeiten, die immer vorhanden sind.

Das Interface-Projekt bietet dafür noch viele weitere Möglichkeiten zur Beschreibung der Schnittstelle, beispielsweise die Möglichkeit, die „Wire Representation“ zu definieren. Der Default für Lagom ist es, Nachrichten als JSON-Objekte zu übermitteln. Doch man ist nicht auf JSON festgelegt: Durch einen MessageSerializer können beispielsweise pro Service oder pro Objekttyp andere Repräsentationen implementiert werden. Auch Content-Negotiation ist möglich. Weiterhin ist ein Konzept zur Versionierung von Services vorgesehen.

Sehen wir uns das Interface-Projekt helloworld-api näher an – hier eine vereinfachte Variante des generierten Projekts. Zentrales Interface ist HelloService:

(Listing 1) – HelloService.java
public interface HelloService extends Service {
    ServiceCall<NotUsed, String> hello(String id);

    @Override
    default Descriptor descriptor() {
        return named(„hello“).withCalls(
            pathCall(„/api/hello/:id“, this::hello)
        ).withAutoAcl(true);
    }
}

Jede Funktion des Microservices ist eine Methode mit einem Rückgabewert vom Typ ServiceCall. Die Abbildung auf URL-Endpunkte erfolgt in der Default-Implementierung von descriptor(), Parameter der Methoden werden in der URL mit Doppelpunkt-Platzhaltern notiert. Auch der Name des Service (helloservice) für die Service-Discovery wird hier festgelegt. Per Default sind die Service-Endpunkte von außen nicht erreichbar; der Methodenaufruf withAutoAcl(true) erlaubt einen Zugriff auf alle definierten URLs.

Die beiden Typ-Parameter von ServiceCall geben den Eingabeund Ausgabedatentyp an: Hier können entweder Basistypen wie String verwendet werden oder aber komplexere, passend serialisierbare Datentypen (im Beispiel GreetingMessage). Werden keine Daten erwartet bzw. zurückgegeben, kommt der spezielle Typ NotUsed quasi als Platzhalter zum Einsatz. Im Implementierungsprojekt des Microservices wird das eben definierte Interface implementiert:

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

Jede Methode führt die tatsächliche Implementierung nicht direkt aus, sondern liefert quasi ein „Kochrezept“ hierfür: Der Rückgabewert ServiceCall ist nämlich nichts anderes als ein Lambda, welches von der Eingabe (vom Typ des ersten Typparameters) auf eine CompletionStage des zweiten Typparameters abbildet. Die gesamte Verarbeitung erfolgt also asynchron. In unserem simplen Beispiel wird direkt ein erfülltes Future zurückgeliefert. Komplexere Abläufe, in denen auf Rückgabewerte anderer asynchroner Funktionen gewartet wird, werden mit den verschiedenen then Methoden der CompletionStage verkettet. Diese Folgen von .thenApplyAsync mögen am Anfang etwas ungewohnt scheinen, durch die Gleichmäßigkeit der Struktur bleibt der Code aber gut lesbar. Nun muss unsere Service-Implementierung noch registriert werden. Dies geschieht durch in der Implementierung einer Modul-Klasse:

(Listing 3)
public class HelloModule extends AbstractModule
        implements ServiceGuiceSupport {
    @Override
    protected void configure() {
        bindService(HelloService.class, HelloServiceImpl.class);
    }
}

Sollte dieses Projekt mehrere Interfaces implementieren, können diese natürlich hier ebenfalls registriert werden. Zuletzt muss dieses Modul noch in der Konfigurationsdatei applications.conf registriert werden:

(Listing 4)
play.modules.enabled += sample.helloworld.impl.HelloServiceModule

Wer bei der Struktur des Projekts und beim obigen Kürzel an das Play-Framework denkt, liegt richtig: Das Web-Anwendungs-Framework Play bildet zusammen mit der Aktorenbibliothek Akka einen robusten und erprobten Unterbau für Lagom.

Einen Service konsumieren

Nehmen wir an, wir wollen den eben beschriebenen Service in einem zweiten Microservice verwenden. Wir erstellen hierfür zwei neue Subprojekte greetingapi und greeting-impl; in der Implementierung wollen wir den helloService verwenden, daher fügen wir dessen API zu den Paketabhängigkeiten hinzu:

(Listing 5) – pom.xml
<dependency>
    <groupId>${project.groupId}</groupId>
    <artifactId>hello-api</artifactId>
    <version>${project.version}</version>
</dependency>

Erstellen wir ein einfaches Interface:

(Listing 6) – GreetingService.java
public interface GreetingService extends Service {
    ServiceCall<GreetingData, String> greet();

    @Override
    default Descriptor descriptor() {
        return named(„greeting“).withCalls(
            Service.restCall(Method.POST, „/api/greet“, this::greet)
        ).withAutoAcl(true);
    }
}

Hier kommt für die Parameter ein Nicht-Standard-Datentyp GreetingData zum Einsatz, den wir ebenfalls im Interface-Projekt definieren:

(Listing 7) – GreetingData.java
@Immutable @JsonDeserialize
public final class GreetingData {
    public final String name;
    public final String message;

    @JsonCreator
    public GreetingData(String name, String message) {
        this.name = Preconditions.checkNotNull(name, „name“);
        this.message = Preconditions.checkNotNull(message, „message“);
    }
}

Die resultierende Klasse besitzt unveränderliche Members sowie alles, was für eine Wandlung nach JSON (und zurück) nötig ist. Nun zum eigentlich spannenden Teil, der Implementierung des Service:

(Listing 8) – GreetingServiceImpl.java
public class GreetingServiceImpl implements GreetingService {
    final HelloService helloService;

    @Inject
    public GreetingServiceImpl(HelloService helloService) {
        this.helloService = helloService;
    }

    @Override
    public ServiceCall<GreetingData, String> greet() {
        return (greetingData) ->
            helloService.hello(greetingData.name).invoke()
                .thenApply(greeting -> greeting + „\n“
                    + greetingData.message + „\n“);
    }
}

Unser Service möchte den HelloService verwenden, daher lassen wir uns diesen per Dependency-Injection in unsere Klasse reichen. Unter der Haube steckt als Implementierung die Service-Discovery, der Zugriff übers Netz, die Abbildung auf URLs, die Serialisierung und Deserialisierung sowie weitere Logik
wie das Cirucit-Breaker-Pattern. Letzteres sorgt dafür, dass für den Fall, dass der Dienst nur langsam oder gar nicht reagiert, keine weiteren Anfragen gestellt werden, sondern direkt lokal mit einem Fehler quittiert werden, bis sich der Dienst erholt hat.

Mit helloService.hello(greetingData.name).invoke() wird unser helloService aufgerufen. Da sämtliche I/O-Vorgänge asynchron bearbeitet werden, ist der Rückgabewert nicht direkt das Ergebnis, sondern ein Future hierfür. Um unser gewünschtes Ergebnis zusammenzubauen, muss auf das Future eine weitere Aktion folgen, welche ausgeführt wird, sobald das Future des Microservice-Aufrufs erfüllt ist – dies erledigen wir mittels thenApply. Voilà! Wir haben unseren ersten Microservice benutzt!

Bleibt nur noch die Implementierung des Moduls und dessen Angabe in der Konfigurationsdatei applications.conf wie im ersten Service. Im Modul wird einerseits unser neuer Service (wie bereits gesehen) registriert; ausserdem muss der helloService als Microservice-Client gebunden werden:

(Listing 9) – GreetingServiceModule.java
public class GreetingModule extends AbstractModule implements
ServiceGuiceSupport {
    @Override
    protected void configure() {
        bindClient(HelloService.class);
        bindServices(serviceBinding(GreetingService.class,
            GreetingServiceImpl.class));
    }
}

Falls das Kommando mvn lagom:runAll weiter im Hintergrund lief, konnten wir schön beobachten, wie nach jeder Code-Änderung ein entsprechender Reload stattfand: Unsere Änderungen waren unmittelbar testbar. Nun können wir unseren Greeting-Service ausprobieren:

(Listing 10)
$ curl -H „Content-Type: application/json“ -X POST -d
    ‚{„name“:“world“, „message“:“Have fun with Lagom!“}‘
    http://localhost:9000/api/greet

Hello, world
Have fun with Lagom!

Erst der Anfang!

Die Kapselung von Services in Interfaces erleichtert das Erstellen von Microservices erheblich – es abstrahiert weg von der „Leitungskommunikation“, ermöglicht eine komplett asynchrone Verarbeitung und bietet passende Werkzeuge für das Behandeln von Fehlersituationen.

Der Werkzeugkasten von Lagom bietet aber noch weitaus mehr: Dienste können nicht nur nach dem Request-Reply-Prinzip antworten, es gibt auch die Möglichkeit, Datenströme als Antwort zu modellieren. Für die Verarbeitung von Daten nach der CQRS-Idee (Command Query Responsibility Segregation) gibt es Bibliotheksfunktionen und eine Standardimplementierung, welche die Daten in Cassandra speichert. Werden mehrere Instanzen eines Microservices gestartet, so bilden diese automatisch einen Cluster, in welchem die Verarbeitung der eigenen CQRS-Nachrichten verteilt werden.

Lagom bietet auch eine Message-Broker-API, um das Verteilen von Benachrichtigungen an andere Services mittels einer persistenten Nachrichten-Queue zu ermöglichen. Als Standard kommt hier Kafka zum Einsatz. Die Verwendung eines Message-Brokers geht Hand in Hand mit dem CQRS-Ansatz. Gemeinsam lassen
sich sehr robuste Anwendungen erstellen.

Zu guter Letzt ist auch die Service-Discovery ein modularer Bestandteil. Standardimplementierungen sind für eine statische Konfiguration sowie für Lightbends Produkt Conductr verfügbar. Implementierungen für beispielsweise Zookeeper oder Consul sind jedoch in der Community bereits in Arbeit.

Fazit:

Alles in allem hat Lagom das Zeug, Entwicklern an vielen Stellen begeisterte „Ja, genau so!“-Ausrufe zu entlocken und das Erstellen von microservicebasierten Anwendungen zu erleichtern. Sehr reizvoll ist die Tatsache, dass die verschiedenen Konzepte wie asynchroner Service-Zugriff, CQRS oder Messaging hinter generischen APIs stecken, hinter denen die spezifischen Zugriffe auf die im Einzelfall gewünschten Services stecken. Sie sind eine schöne Abstraktion für alle Werkzeuge die man benötigt, um Reactive-Microservices zu bauen.

 

Dr. Stefan Schlott

Dr. Stefan Schlott ist Advisory Consultant bei der Firma BeOne Stuttgart GmbH und dort als Entwickler, Architekt und Trainer im Java-Umfeld tätig. Security und Privacy gehören ebenso zu seinen Schwerpunkten wie skalierbare Architekturen und das breite Feld der verschiedenen Webtechnologien.
Einmal jährlich ist er zusätzlich als Dozent bei der Dualen Hochschule Baden-Württemberg aktiv.

(Visited 53 times, 1 visits today)

Leave a Reply