Der perfekte Microservice

Trennungen

Wie lassen sich aber Microservices fachlich sinnvoll zuschneiden? Ein zunächst naheliegender Schnitt der Microservices ist der nach Domänenobjekten. Für die Beispieldomäne hieße das zum Beispiel, dass es einen Kunden- einen Warenkorb- und einen Produkt-Service gibt. Bei näherer Betrachtung erweist sich dieses Schneiden aber als suboptimal. Insbesondere beim Blick auf den Warenkorb-Service wird schnell klar, dass dieser direkt von den beiden anderen Services abhängt und daher bei jeder Änderung einer der Services mit zu ändern ist.

Eine solche Abhängigkeit ist auf jeden Fall zu vermeiden, da man sich so den zusätzlichen Overhead für eine Microservice-Architektur einkaufen würde, ohne dabei im Gegenzug die gewünschte Unabhängigkeit bezüglich Entwicklung und Bereitstellung der Services zu gewinnen.

Wie lassen sich die Services also so abgrenzen, dass sie möglichst wenige fachliche Berührungspunkte und somit auch wenig Abhängigkeiten untereinander besitzen? Wenige Berührungspunkte bedeutet in dem Kontext, dass sich die Services zur Laufzeit selten gegenseitig aufrufen müssen beziehungsweise Änderungen an einem Service nicht automatisch auch Änderungen an aufrufenden Services mit sich bringen. Um festzustellen, wie häufig sich Microservices zur Laufzeit gegenseitig aufrufen, reicht ein Blick auf die Use Cases. Idealerweise lässt sich ein Use Case mit möglichst wenig (am besten gar keinen) Microservice-Interaktionen abarbeiten. Der ideale Microservice ist demnach für einen kompletten Use Case verantwortlich und benötigt für dessen Abarbeitung keinen weiteren Microservice.

In der Praxis lässt sich das selten realisieren. Dennoch wird deutlich, dass das Abgrenzen der Microservices nach Use Cases ein sinnvolles Vorgehen ist. Überträgt man das auf den Web-Shop, zeigt sich, dass die Probleme des Aufteilens nach Domänenobjekten dadurch behoben werden. Betrachtet man einen klassischen Use Case "Kunde kommt auf die Website, sucht ein Produkt und kauft es", gibt es Teilschritte, die unterschiedliche Microservices problemlos behandeln können. Die Produktsuche kommt zum Beispiel vollständig ohne Abhängigkeiten zu den anderen Services aus. Und auch der zum Ändern von Kundendaten benötigt keine Abhängigkeiten zu anderen Services.

Es sei daran erinnert, dass die Warenkorbverwaltung beim Aufteilen nach Domänenobjekten der kritische Service war, da er starke Abhängigkeiten zu anderen Microservices besitzt. Orientiert man sich allerdings an die Abschnitte über Shared Kernel und Core Domain, wird deutlich, dass die Warenkorbverwaltung nur einen kleinen Teil der Domänenobjekte von Kundenverwaltung und Produktsuche benötigt, nämlich die Core-Versionen von Kunde und Produkt. Bei sinnvoller Modellierung der Core Domain hätte also die Warenkorbverwaltung keine Abhängigkeit zur Kundenverwaltung oder Produktsuche, sondern nur zur Core Domain. Der Knopf "In den Warenkorb legen" besteht lediglich aus einem Aufruf der Warenkorbverwaltung, der ein Core Product übergeben wird.

Dass man dennoch nicht ganz ohne Abhängigkeiten zwischen Microservices auskommt, verdeutlicht das Betrachten des Bestellvorgangs: Hat sich der Kunde einmal entschieden, den Inhalt seines Warenkorbs zu bestellen, werden die benötigten Daten an den Checkout-Microservice übergeben. Auch hier ist festzustellen, dass lediglich Core-Kunde und -Produkt zu übergeben sind. Der Checkout-Microservice benötigt nun einen Teil der Daten, die im Kundenverwaltungs-Microservice eingegeben wurden: Lieferadresse, Zahlungsinformationen usw. Sie sollen sich auch im Bestellprozess noch ändern lassen. Das führt zur Frage der Datenhaltung: Wie sollen Daten in Microservices gespeichert werden? Wer darf Daten ändern? Was stellt die Datenkonsistenz sicher?

Ein Gendankenspiel vorweg: Welcher Vorteil wäre erreicht, wenn jeder Microservice für sich autark – Stichwort "Bounded Context" – auf einem eigenen Domänenmodell agiert, sich aber alle Services eine Datenquelle, zum Beispiel eine Datenbank und deren Tabellen, teilen? Keiner? Korrekt! Eine Änderung der Datenbank würde in der Regel auch die Änderung mehrerer Services nach sich ziehen. Optimal wäre es dagegen, wenn jeder Microservice auf seiner eigenen Datenquelle arbeiten könnte.

Besitzt ein Service eine eigene Datenquelle, organisiert er sich für das Abarbeiten eines Use Case lediglich rudimentäre Informationen von anderen Services – Stichwort "Core Domain" – und nimmt sie als Basis, um in seinen eigenen Datenquellen nach der für ihn relevanten Modellrepräsentation zu suchen. Ein Liefer-Service könnte so zum Beispiel beim Kunden-Service via Core-Domain-Objekt "Kunde" die für den Bestellvorgang relevante Kundennummer erfragen und im Anschluss mit dieser Information innerhalb seiner eigenen Datenquelle nach passenden Lieferadressen suchen und sie zur Auswahl anbieten.

Es stellt sich die Frage, wie der Service an die Daten für seine Datenquellen kommt und sich diese konsistent halten lassen. In der Praxis wird das zum Beispiel durch replizierte Datenbanken/-tabellen, Event-Mechanismen und Microservice-eigene Caches erreicht. Im Beispiel würde die Datenquelle des Liefer-Services durch die Stammdaten des Kunden-Services gefüllt werden. Ändern sich Lieferdaten im Kunden-Service, werden diese Änderungen über Replikation oder Event-Mechanismen an den Liefer-Service übermittelt.

Wichtig ist dabei, dass zu jedem Zeitpunkt klar definiert ist, was die aktuelle Version eines Datensatzes ist und welcher Service zu welchem Zeitpunkt die Hoheit über die Daten hat. Am einfachsten wird das erreicht, indem man für jeden Datensatz einen Microservice definiert, der für ihn verantwortlich ist. Im Beispiel wäre das für die Kundendaten der Kunden-Service, für die Produktdaten der Produkt-Service und so weiter. Ändert nun ein anderer Microservice Daten – der Kunde gibt zum Beispiel im Rahmen des Checkout-Prozesses (Checkout-Service) eine neue Lieferadresse an –, muss die Änderung der Daten über den verantwortlichen Microservice geschehen, im Beispiel also den Kunden-Service. Auf diese Weise ist sichergestellt, dass er immer die aktuellste Version des Datensatzes verwaltet.

Eine verteilte Datenhaltung auf Basis replizierter Daten in einem stark verteilten System führt unweigerlich zur Frage der Datenkonsistenz, da verteilte Transaktionen in diesem Kontext nur schwer zu realisieren sind. In der Praxis wird bei stark verteilten Systemen, wie sie im Falle von Microservices vorliegen, gerne auf eine etwas abgeschwächte Variante des ACID-Paradigma zurückgegriffen: BASE (Basic Availability, Soft-State, Eventually Consistent).

Sie legt nahe, dass ein Zugriff auf gewünschte Daten zu jedem Zeitpunkt möglich ist, diese aber eventuell nicht 100 Prozent aktuell sein könnten. Interessant ist, dass viele reale Use Cases ebenso funktionieren. Ein schönes Beispiel hierfür finden sich im Blog-Beitrag "Starbucks does not use Two-Phase Commit", in dem Gregor Rambling beschreibt, wie sich dem Problem der fehlenden Transaktionen mit dem Conversation Pattern begegnen lässt.

Eine besondere Herausforderung beim "richtigen" Zuschneiden von Microservices stellen die zugehörigen Nutzerschnittstellen dar. Hier stellt sich die Frage, ob die UI als direkter Bestandteil des Services gesehen wird oder nicht. Trennt man die Microservices strikt nach Use Cases, ist es erst mal naheliegend, dass sie auch jeweils die für ihren Use Case zugehörige UI mitliefern. Das hat mehrere Vorteile: Zunächst einmal ist eine einheitliche User Experience innerhalb des Use Case gewährleistet. Die Umsetzung der UI erfolgt dabei durch das Team, das sich fachlich am stärksten mit dem Use Case beschäftigt hat. Da sich die UI auch als Consumer eines Services ansehen lässt, wird in dem eben beschriebenen Szenario – Microservice(-Team) liefert die zugehörige UI – die Entwicklungsperformance deutlich erhöht, da das Team die meisten Schnittstellen und Daten des Microservice zur Verfügung stellt, das sie auch verwendet. Ein weiterer positiver Nebeneffekt ist zudem, dass weniger Schnittstellen für externe Services benötigt werden.

Der große Nachteil dieses Ansatzes ist die über Microservice-Grenzen hinweg verteilte User Experience. Wenn jeder Microservice seine eigene UI liefert, können die einzelnen sich mitunter in "Look und Feel" deutlich unterscheiden. Dem Problem kann man nur mit einem ausführlichen Styleguide, inklusive gemeinsam genutzter Design-Ressourcen wie CSS und Images, sowie einer permanenten Überwachung seiner Einhaltung Herr werden. Ein weiterer Nachteil ergibt sich dadurch, dass die endgültige UI erst auf Seiten des Clients orchestriert wird, was zu unnötigen vielen Server-Calls über das relativ inperformante Internet führt.

Aus den oben genannten Gründen findet man daher in der Praxis häufig einen Ansatz, bei dem die UI separat entwickelt wird und via API-Gateway auf das Backend zugreift. Das Gateway wiederum führt die eigentlichen Microservice-Calls aus, bereitet die Ergebnisse für die jeweiligen UIs auf und liefert das Ergebnis an diese aus. Als positiver Nebeneffekt des Ansatzes lassen sich die verschiedenen UI-Typen (z. B. für mobile Endgeräte, Web-Browser, Spielkonsolen, TVs) mit unterschiedlichen Daten beziehungsweise Visualisierungen versehen.

Neben den beiden genannten, gegensätzlichen Ansätzen existiert noch eine dritte Variante. Bei ihr stellen die einzelnen Microservices lediglich UI-Komponenten (z. B. in Form von HTML-Widgets) zur Verfügung, die eine zentrale UI verwenden kann.

Für welche der Varianten man sich entscheidet, hängt, wie so oft, von den Gegebenheiten ab. Wie viele UIs sind zur Verfügung zu stellen? Welche Anforderungen existieren an sie? Und last, but not least wie sind die einzelnen Teams aufgestellt, welche die Microservices und die UI umsetzen sollen.