Was Modularität mit Microservices zu tun hat

Modularität für Microservices: Es lebe das Modul!

Ulf Fildebrandt

(c) Shutterstock/aurielaki

Jede Woche stellen wir auf JAXenter einen Themen-Track der JAX 2016 ins Zentrum – diese Woche: Microservices. In diesem Artikel geht es um die Verbindung zwischen etablierten Best Practices der modularen Software-Entwicklung und der aktuellen Microservice-Debatte. Alle Infos zum Microservices-Track auf der JAX gibt es hier.

Modularität gilt in der Softwareentwicklung als Grundvoraussetzung, um wartbare Systeme zu bauen. Im Rahmen von Microservices achtet man allerdings meist nicht mehr auf Modularität als Designprinzip, womöglich weil es ein altes Prinzip ist und nicht mehr en vogue ist. Aber dennoch liefern die Grundregeln für Modularität interessante Erkenntnisse für Microservices.

W-JAX
Mike Wiesner

Data- und Event-driven Microservices mit Apache Kafka

mit Mike Wiesner (MHP – A Porsche Company)

Niko Köbler

Digitization Solutions – a new Breed of Software

with Uwe Friedrichsen (codecentric AG)

Software Architecture Summit 2017
Dr. Carola Lilienthal

The Core of Domain-Driven Design

mit Dr. Carola Lilienthal (Workplace Solutions)

Sascha Möllering

Reaktive Architekturen mit Microservices

mit Sascha Möllering (Amazon Web Services Germany)

Modularität für Microservices

Bevor in diesem Artikel über Modularität gesprochen werden kann, sollten ein paar Worte über den Begriff Microservices verloren werden. Den aktuellen Hype in der Softwareentwicklung löste ein Artikel von Martin Fowler im Jahre 2014 aus, der Begriff Microservices existierte aber eigentlich im Umfeld des bekannten OSGi-Standards im Java-Umfeld schon länger. Peter Kriens prägte den Begriff in einem Blog im Jahre 2010 für Services im Rahmen des OSGi-Standards.

Bei OSGi handelt es sich um eine Spezifikation, die darauf beruht, dass man das Coding in modulare Einheiten aufteilt, Bundles genannt, die über Services miteinander kommunizieren. Gängige Prinzipien wie Schnittstellendesign oder Versionierung von Packages und Abhängigkeiten sind Teil des Standards, sodass am Ende ein Verwender ein System erstellen kann, das sich aus vielen Einzelteilen zusammensetzt. Wie diese Einheiten miteinander interagieren, regelt die Spezifikation. Im Moment sind nur Implementierungen für Java verfügbar, aber dort existieren mehrere. Die bekanntesten sind Eclipse Equinox oder Apache Felix.

OSGi verwendet Services als zentrales Element, aber im Laufe der Zeit gab es Probleme mit der Abgrenzung von Services im SOA-Sinne. Solch ein SOA-Service ist für gewöhnlich sehr schwergewichtig, und in den meisten Fällen läuft jeder Service auf einer eigenen Laufzeit, vielleicht sogar auf einem eigenen Server.

Um die Services von OSGi abzugrenzen, hat man den Begriff Microservices eingeführt. Services in der OSGi-Spezifikation laufen in einer JVM, also innerhalb desselben Prozesses, sodass lokale Methodenaufrufe ausreichen, um die Funktionalität zu verwenden. In den letzten Jahren gab es Diskussionen in der OSGi-Community, dass der Begriff Microservice eigentlich sogar geklaut wäre. An dieser Stelle soll nicht dafür oder dagegen argumentiert werden. Es soll nur aufgezeigt werden, dass es einige Überschneidungen auf diesem Gebiet gibt, die sogar dazu geführt haben, dass ein Begriff aus dem Bereich OSGi passend für eine neue Idee angesehen wurde. Es stellt sich jetzt die interessante Frage, warum ein Begriff aus dem Umfeld der OSGi-Spezifikation im Umfeld von verteilten Systemen wieder auftaucht. Das legt schon nahe, dass Microservices viel mit Modularität zu tun haben.

Die Stufen der Modularität im Modularity Maturity Model
Im OSGi-Umfeld existiert nicht nur die Spezifikation, es haben sich auch etliche Softwareentwickler Gedanken über die Prinzipien von modularer Softwareentwicklung gemacht. An dieser Stelle soll nur das Modularity Maturity Model erwähnt werden, das im weiteren Verlauf dazu dienen soll, die Stufen von Modularität und seine Bedeutung für die Software zu erklären. Das Modell definiert sechs Stufen:

  1. Ad hoc: Das ist quasi der Ursprungszustand eines monolithischen Systems. Alle Klassen sind gleichzeitig verfügbar und können sich referenzieren. Es wird keine Struktur vorausgesetzt.
  2. Modular: Bezeichnet das Auftreten von Einheiten, die versioniert sind und eine Identität besitzen, also einen eindeutigen Identifier.
  3. Modularity: Umfasst die Verwaltung von Abhängigkeiten zwischen den Modulen, sodass in einem System klar definiert ist, welche Einheit von einer anderen abhängt.
  4. Loose coupling: Fordert, dass ein Modul eine klar definierte Schnittstelle besitzt und andere Verwender auch nur diese Schnittstelle verwenden. Die interne Implementierung ist nicht sichtbar und wird auch nicht angesprochen.
  5. Devolution: Bringt ein Repository ins Spiel, in dem die Module abgelegt werden. Dadurch können die Abhängigkeiten eines Moduls immer eindeutig aufgelöst werden.
  6. Dynamism: Ermöglicht das unabhängige Update eines Moduls, ohne andere zu beeinflussen, und auch die Möglichkeit, verschiedene Versionen eines Moduls in derselben Laufzeit auszuführen.

Bei der Betrachtung eines Systems sollte man darauf achten, welche Eigenschaften es besitzt: Sind die Teile des Systems klar aufgeteilt und besitzen Versionsinformationen? Sind die Abhängigkeiten deklariert? Folgt die Implementierung bei einer Kommunikation über die Grenzen von zwei Modulen hinweg einer Schnittstellen-/Implementierungsseparierung? Lassen sich Teile des Systems einfach austauschen, vielleicht sogar im laufenden Betrieb?

Lesen Sie auch: Solide Microservices bauen: Alte Prinzipien mit neuen Services

Anhand der Antworten zu diesen Fragen kann man beurteilen, wie fortgeschritten ein System bezüglich der Modularität ist. Jede dieser Stufen erreicht ein System natürlich nur, nachdem man einen gewissen Preis, sprich Entwicklungsaufwand, entrichtet hat. Für eine wirklich einfache Webapplikation ist es fast nie notwendig, dass die Bestandteile dynamisch ausgetauscht werden können. Es ist für einen Softwareentwickler sicher reizvoll, ein solches Feature zu programmieren, aber für einen Endbenutzer hat es keinen großen Vorteil.

Betrachtet man allerdings größere Systeme, die über Jahre weiterentwickelt werden, dann müssen früher oder später Teile ausgetauscht werden. Genau hier kommt Modularität ins Spiel, denn sie ermöglicht den Austausch solcher Implementierungen. Und je höher die Stufe ist, die das System im Sinne des Modularity Maturity Models erklommen hat, um so einfacher ist ein solcher Austausch.

Was Modularität mit Microservices zu tun hat

Wenn man das Modularity Maturity Model als sinnvoll bei der Implementierung eines Systems betrachtet, stellt sich die Frage, was es mit Microservices zu tun hat. An den Fragen, die zur Einordnung in die Stufen des Modells dienen, kann man bereits erkennen, dass viele der Qualitäten, die meistens Microservices zugeschrieben werden, auch auf Module zutreffen. Wenn man genauer darüber nachdenkt, handelt es sich beim Modularity Maturity Model im Prinzip um eine Checkliste für die Qualitäten der zu implementierenden Microservices.

Ein Microservice ist eine unabhängig installierbare Einheit mit einer klar definierten Schnittstelle, die über ein Netzwerk aufgerufen werden kann. Im Gegensatz zu den Modulen in einer OSGi-Laufzeit, die alle im selben Prozess laufen, geht man bei Microservices davon aus, dass sie jeweils in einem eigenen Prozess laufen. Dadurch können sie ihre eigene Laufzeitumgebung mitbringen, d. h. in Java implementiert sein, oder in C++, in Python, JavaScript auf Node.js oder alle anderen Laufzeitumgebungen. Dennoch sollten alle Microservices im Hinblick auf gewisse Anforderungen gleich sein. Für eine nähere Betrachtung ihrer Qualitäten soll jetzt das Modularity Maturity Model dienen.

Ad hoc: der Ursprungszustand
Diese Stufe der Modularität beinhaltet im Prinzip keine Qualitäten. Das gesamte System ist in einem Block implementiert. In wohlwollenden Beschreibungen wird das auch als monolithischer Architekturstil bezeichnet. Auf Microservices übertragen, wäre die gesamte Funktionalität in einem Microservice implementiert, was dazu führen würde, dass ein Update oder die Ersetzung von Funktionalität nur durch die Ersetzung des gesamten Monolithen geschehen kann. Mehr Worte sollte man über diese Stufe im Rahmen der Modularität auch nicht verlieren.

Lesen Sie auch: Microservice-Plattform VAMP: Quelloffene Steuerzentrale für Containerplattformen

Jetzt geht’s los: modular
Die erste Stufe, die für modulare Softwareentwicklung wirklich Sinn macht, ist die Dekomposition eines Systems in kleinere Einheiten, die einen eindeutigen Identifier besitzen und auch versioniert sind (Abb. 1).

Abb. 1: Ebene 2 – Modular

Auf Microservices übertragen, besteht die Herausforderung darin, dass das System in kleinere Services aufgeteilt werden muss. Wie die Services kommunizieren, ist noch nicht genau definiert, aber immerhin ist das System schon nach funktional zusammengehörenden Services aufgeteilt. Wie schwierig diese erste Stufe schon ist, sieht man alleine daran, wie oft in einem laufenden System ein Refactoring passieren muss. Wenn der erste Schnitt in Microservices nicht gut ist, wird ein Refactoring eines Microservice schwierig, denn die Funktionalität ist ja auf verschiedene Prozesse verteilt.

Damit ist auch schon einer der Nachteile von Microservices angesprochen, denn Refactoring ist definitiv nicht so einfach, wie in einem monolithischen System. Wenn das Refactoring ansonsten nur darin bestand, Coding von einer Klasse in eine andere zu verschieben, so muss man es jetzt zwischen verschiedenen Prozessen austauschen. Dadurch kann sich im schlimmsten Fall auch die Schnittstelle des Service ändern.

Abhängigkeiten in Modularity verwalten
Bei der nächsten Stufe werden die Abhängigkeiten der Module verwaltet. Darunter versteht man die Deklaration von benötigten anderen Modulen inklusive ihrer Versionsinformationen (Abb. 2).

Abb. 2: Ebene 3 – Modularity

OSGi baut hier eine Infrastruktur auf, denn ein Bundle definiert genau, welche Packages importiert werden. Das ist wiederum für den Classloader auf der Java-Seite relevant, denn nur importierte Klassen und Packages sind zugreifbar. Dadurch kann die OSGi-Laufzeit genau feststellen, welches Bundle von welchem anderen abhängt. Darüber hinaus liefert OSGi auch noch Services, die in einer Registry registriert werden oder aus der Serviceinstanz herausgeholt werden können.

Das Framework ermöglicht durch die Deklaration von Abhängigkeiten und die Registrierung von Services, die Kapselung der Software einzuhalten. Es ist nicht mehr nur der Programmiersprache überlassen, die Modularität von einzelnen Abschnitten zu erzielen. Gerade den Ansatz, die Instanzen von Services in eine Registry einzutragen und den Zugriff über ein solches Verzeichnis zu kontrollieren, kann man von OSGi auf Microservices übertragen. Das bedeutet, dass ein neuer Microservice sich bei einer Registry registrieren muss und Instanzen nur über diese Registry geholt werden.

Lesen Sie auch: Microservices: Consumer-driven Contract Testing mit Pact

An dieser Stelle ist ein Exkurs zu einem der bekanntesten Vertreter einer Microservices-Architektur angebracht: Netflix hat im Laufe der letzten Jahre eine Reihe von Frameworks Open Source zur Verfügung gestellt. Eines dieser Frameworks ist Eureka, das eine Registry für Services zur Verfügung stellt. Netflix hat eine große Menge von Microservices implementiert, die miteinander kommunizieren müssen und auch unabhängig voneinander weiterentwickelt werden. Wenn ein neuer Service verfügbar ist, muss er sich bei der Registry bekannt machen, und ein Verwender kann über diese Registry die Instanzinformationen erhalten, um den Microservice aufzurufen.

Wie nicht anders zu erwarten, funktioniert das Management der Abhängigkeiten auf Microservices-Ebene natürlich auf Serviceebene, d. h. ein Service ist das Element, das verwaltet wird.

Loose Coupling – koppeln und entkoppeln
Ein bekanntes Pattern in der Softwareentwicklung ist die Trennung zwischen Schnittstelle und Implementierung. Im Rahmen der Modularität ist eine solche Aufteilung auch wichtig, weil es erst dadurch möglich wird, zwei Teile der Implementierung wirklich voneinander zu entkoppeln (Abb. 3).

Der berühmte Vertrag zwischen zwei Modulen sieht folgendermaßen aus: Ein Verwender und ein Anbieter haben sich auf eine gemeinsame Schnittstelle geeinigt, meistens stellt der Anbieter sie zur Verfügung. Wenn diese Schnittstelle existiert, ist es möglich, beide Seiten unabhängig voneinander weiterzuentwickeln.

Abb. 3: Ebene 4 – Loose Coupling

Ein Beispiel ist ein User-Interface-Service, der dazu gedacht ist, Daten anzuzeigen. Der Service, der für das Anbieten von Daten verantwortlich ist, definiert seine Ausgabe, und der User-Interface-Service verwendet sie. Es ist dem User-Interface-Service nun selbst überlassen, welche Technologien zur Darstellung verwendet werden – wie beispielsweise jQuery und HTML5. Das sind alles Entscheidungen, die komplett separat für den User-Interface-Service getroffen werden können. Auf der anderen Seite kann der Datenservice die Persistenz der Daten selbst entscheiden. Ob die Daten in einer relationalen Datenbank liegen oder in einer NoSQL-Datenbank wie Cassandra, MongoDB oder Ähnlichem, bleibt den Entwicklern dieses Service überlassen. Darüber hinaus können sie sogar die Implementierung später ändern. Ein Modul ist ersetzbar (engl. replaceable). Wenn die Entwickler eines Systems feststellen, dass sie keine relationale Datenbank brauchen, sondern eine dokumentbasierte Datenbank wie MongoDB vollkommen ausreicht, können sie die Implementierung ändern, ohne den User-Interface-Service zu beeinflussen.

Ein kleiner Exkurs bezüglich der Daten sei an dieser Stelle eingeschoben. Was beim Design meistens übersehen wird, ist, dass eine Datendefinition auch eine Form von Kopplung darstellen kann. Wenn in einem System genau auf die Strukturierung geachtet, aber jedes Modul im System dasselbe Datenschema verwendet, kann man dieses Schema nur mit großem Aufwand weiterentwickeln. Denn alle Verwender müssen Anpassungen vornehmen.

Bei Services wird meistens nur über den HTTP-Zugriff gesprochen, also REST oder SOAP oder eine andere Technologie, aber sobald es um die Datenschicht geht, kann genauso gekoppelt sein. Hier muss während der Definition eines Systems ein ähnlicher Aufwand einfließen.

Devolution: ein Repository kommt ins Spiel
Eine andere Frage bei der Modularität besteht darin, wo die Module herkommen. Wenn ein System zusammengestellt wird, gibt es für gewöhnlich einen Build, der alle Sourcen baut und dann daraus das System assembliert. In der letzten Zeit gab es allerdings andere Build-Systeme wie Maven, die darauf basieren, dass die verschiedenen Bestandteile einen eigenen Lifecycle besitzen, also unabhängig voneinander entwickelt werden können. Das setzt voraus, dass die Bestandteile, die Module, unabhängig gespeichert werden. Ein Zusammenstellen eines Systems besteht nur noch darin, dass eine bestimmte Version eines Moduls aus dem Repository gezogen und dann mit anderen zusammengepackt wird.

Die Module werden in ein Repository gelegt und sind für alle Verwender verfügbar (Abb. 4). Jeder kann herausnehmen, was er gerade braucht. Diese Idee eines Repositories hat auch in der OSGi-Community Einzug gehalten, aber hier wird nicht schon zur Build-Zeit zusammengestellt. Stattdessen ist es möglich, Module zur Laufzeit herunterzuladen und in der Laufzeit zu installieren. Umgesetzt wurde dieser Gedanke in Apache Felix OSGi Bundle Repository (OBR) oder auch Apache ACE.

Auf Microservices übertragen, würde es bedeuten, dass die Implementierungen von Services in einem Repository abgelegt und bei Bedarf gestartet werden. Da Microservices keine Spezifikation sind, sondern ein Prinzip, gibt es viele Möglichkeiten, dieses Konzept zu implementieren. Die aktuell Interessanteste besteht sicher darin, einen Blick auf das Docker-Projekt zu werfen.

Lesen Sie auch: Microservices im Experten-Check: Wann man Microservices nutzen sollte und wann eher nicht

Im Gegensatz zu anderen Virtualisierungsinfrastrukturen, die darauf basieren, dass komplette virtuelle Maschinen mit Betriebssystem und allen Layern gestartet werden, bietet Docker eine sehr leichtgewichtige Infrastruktur, um Container zu starten. Container unterscheiden sich von den Images in den Virtualisierungsinfrastrukturen dadurch, dass ein Container den Linux-Kernel mit anderen Containern teilt. Dadurch sind die Ressourcen auf Betriebssystemebene nicht komplett voneinander getrennt, aber die Startzeiten sind viel besser.

Für Microservices ergibt sich alleine dadurch schon eine gute Infrastruktur, um das Zusammenspiel von vielen Services zu testen. Der andere Aspekt beim Docker-Projekt besteht darin, dass Docker auf ein Repository zurückgreifen kann, um eigene Container zu erzeugen. Ein Entwickler baut sich quasi seinen eigenen maßgeschneiderten Container, basierend auf existierenden Containern im Docker Hub Repository.

In diesem Fall ist die Granularität also wieder ein gesamter Microservice, implementiert als Prozess in einem eigenen Container, und nicht einzelne Klassen in einer Laufzeit. Dieses Grundparadigma zieht sich durch alle Stufen der Modularität hindurch, denn sowohl was die Versionierung, die Definition einer Schnittstelle als auch das Repository angeht, wird immer ein Microservice mit einem eindeutigen Identifier und einer Version betrachtet. Bei der Registry zur Verwaltung von Abhängigkeiten zeigte sich diese Granularität genauso, als Beispiel sei Eureka aufgeführt.

Abb. 4: Ebene 5 – Devolution

Dynamism: unabhängig updaten
Als letzte Stufe der Modularität ist die Dynamik zur Laufzeit zu betrachten. Um beim Beispiel von OSGi zu bleiben, ist es bei diesem Framework möglich, ein Bundle zu stoppen, eine neue Version zu installieren und diese neue Version zu starten. Ein Update in einem laufenden System ist demnach möglich, ohne dass man das gesamte System herunterfahren muss. Diese Dynamik ist quasi die Endausbaustufe von Modularität, denn ein laufendes System muss niemals heruntergefahren werden, auch wenn Teile ausgetauscht werden.

OSGi bietet in diesem Rahmen auch die Möglichkeit, verschiedene Versionen desselben Bundles laufen zu lassen, sodass eine neue Version erst gestartet wird und Verwender auf diese neue Version gehen können, wenn sie dazu bereit sind. Es ist also der Verwender, der sich für eine neue Version entscheidet. Die Laufzeit bietet jedoch die Funktionalität, mehrere Versionen gleichzeitig laufen zu lassen.

Microservices sollten in derselben Weise gebaut sein. Denn durch die Dekomposition eines monolithischen Systems verspricht sich ein Entwickler ja die Möglichkeit, die Teile unabhängig voneinander zu entwickeln. Wenn die Microservices jetzt in der Weise gebaut werden, dass eine Menge von ihnen immer gleichzeitig aktualisiert werden müsste, dann würden sie wieder gekoppelt sein, dieses Mal jedoch bezüglich ihres Lifecycles. Das widerspricht der Anforderung an einen Microservice im Hinblick auf die Unabhängigkeit (Abb. 5).

Abb. 5: Ebene 6 – Dynamism

Die Unterstützung von mehreren Versionen ist eine Funktionalität, die sich für das Update eines Systems und die Weiterentwicklung als sehr hilfreich erwiesen hat, denn dadurch kann man die neue Version eines Bundles bzw. Service im System installieren, ohne dass man das alte Bundle, also den alten Service, unmittelbar herunterfahren muss. Erst zu einem späteren Zeitpunkt wird die alte Funktionalität abgeschaltet. Microservices sollten somit dynamisch implementiert sein.

Das Netzwerk als modulare Laufzeit
Wenn man aus den ganzen Betrachtungen über OSGi und Microservices in diesem Artikel eines gelernt hat, dann, dass die Anforderungen im Prinzip gleich sind. Microservices sollen alle Ebenen des Modularity Maturity Models erfüllen, allerdings sind Microservices an sich grobgranularer, denn jeder Microservice läuft in einem separaten Prozess. Die Kommunikation funktioniert über das Netzwerk. Microservices gehen also fort von einzelnen Laufzeitumgebungen wie Java, .NET oder Python und postulieren, dass das Modulsystem das Netzwerk selbst ist. Die Module sind Microservices, und Kommunikation erfolgt immer über Remote-Aufrufe.

Erfahrungen: es ist kompliziert
Alle Vergleiche mit OSGi haben gezeigt, dass Microservices mit hohen Erwartungen an die Modularität gestartet sind, denn alle Versprechungen wie Versionierung, Trennung von Schnittstelle und Implementierung oder Dynamik sollen bei ihnen natürlich eingehalten werden. Das Modularity Maturity Model hat man allerdings aus dem Grunde entwickelt, um herauszufinden, auf welcher Ebene sich ein System befindet. Meistens hatten die Systeme Probleme, die Stufe 3 oder 4 zu erreichen. Das hängt mit dem Aufwand zusammen, der investiert werden muss, um alle Stufen zu erreichen. Es kostet Entwicklungszeit.

Lesen Sie auch: OSGi enRoute: Ein neues Framework für OSGi-Anwendungen

OSGi hat auch gezeigt, dass es kompliziert ist, alle Anforderungen an die Module – Bundles in der OSGi-Welt – zu erfüllen, denn sie müssen die Version korrekt setzen, die Schnittstellen müssen immer den Regeln zufolge entwickelt werden, und es darf keine verdeckten Abhängigkeiten wie Datenbankpersistenzen geben. Diese Komplexität hat auf OSGi ausgestrahlt, denn die Meinungen gehen alle dahin, dass OSGi zu kompliziert ist. Gut für Frameworkentwickler, aber zu schwierig für Anwendungsentwickler. Genau diese Gefahr besteht auch bei Microservices, denn dadurch, dass Microservices auf allen Ebene der Modularität spielen, müssen sie auch sorgfältig gebaut werden.

Aufmacherbild: The Best Modular Solutions von Shutterstock / Urheberrecht: aurielaki

Geschrieben von
Ulf Fildebrandt
Ulf Fildebrandt
Ulf Fildebrandt arbeitet für SAP seit 1998 in verschiedenen Bereichen als Development Architect. Während des letzten Jahrzehnts war er für verschiedene Produkte im SOA-Umfeld verantwortlich und arbeitet seit einigen Jahren an Cloud-basierten Lösungen.
Kommentare

Schreibe einen Kommentar

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