Wie solide sind Microservices?

Solide Microservices bauen: Alte Prinzipien mit neuen Services

Ulf Fildebrandt

© shutterstock / Nattakit Jeerapatmaitree

Jede Woche stellen wir auf JAXenter einen Themen-Track der JAX 2016 ins Zentrum – diese Woche: Microservices. Ulf Fildebrand erörtert in diesem Artikel, wie solide eine Microservices-Architektur sein kann. Weitere Infos zum Microservices-Track auf der JAX gibt es hier.

Microservices bieten Vorteile bei der Abgrenzung von Funktionalität und Verteilung in einem Netzwerk. Auch in der Vergangenheit existierten schon Prinzipien, die ähnliche Ziele verfolgt haben. Eines dieser Prinzipien ist SOLID. Die Frage lautet also: Wie solide sind Microservices?

Alte Prinzipien mit neuen Services

Es ist nicht das erste Mal, dass Softwareentwickler vor dem Problem stehen, dass ein System zu groß geworden ist und ein Entwickler es in seiner ganzen Komplexität nicht mehr verstehen kann. Schon im Rahmen der objektorientierten Programmierung haben sich Verfahren und Prinzipien entwickelt, die hierbei helfen sollen. Die Komplexität soll in handhabbare Objekte, sprich Klassen, aufgeteilt werden. Für das Klassendesign haben sich im Laufe der Jahre einige gute Regeln herausgebildet. Eine der bekanntesten Sammlungen nennt sich SOLID. Diese Abkürzung umfasst die folgenden fünf Prinzipien, aus deren Anfangsbuchstaben sich der Name des Prinzips ergibt:

  • Single Responsibility Principle (SRP): Eine Klasse soll nur eine Aufgabe erfüllen.
  • Open Close Principle (OCP): Eine Klasse soll offen für Erweiterungen sein, aber geschlossen für Modifikationen.
  • Liskov Substition Principle (LSP): Für einen Verwender soll eine Instanz einer Superklasse immer durch seine Subklassen ersetzbar sein.
  • Interface Segregation Principle (ISP): Ein Verwender soll eine Schnittstelle angeboten bekommen, die nur seiner Aufgabe entspricht.
  • Dependency Inversion Principle (DIP): Die Abhängigkeit soll nur von höheren Modulen zu niedrigeren gehen.

Da in den Umgang mit diesen Prinzipien bereits etliche Jahre Erfahrung eingeflossen sind, ist es eine interessante Übung herauszufinden, wie diese Prinzipien beim Design von Microservices helfen können.

Single Responsibility Principle: Eine Aufgabe erfüllen

Das Single-Responsibility-Prinzip besagt, dass eine Klasse nur eine Verantwortung haben darf. Eine Klasse soll eine und nur eine Aufgabe übernehmen. In der Softwareentwicklung haben sich gute Faustregeln entwickelt, um zu prüfen, ob dieses Prinzip wirklich eingehalten wird. Die bekannteste Regel lautet, dass alle Klassen, die mit Manager oder Handler bezeichnet werden, schon dubios sind. Wenn der Entwickler keinen besseren Oberbegriff findet als das Managen von Aufgaben, dann deutet das normalerweise auf eine Klasse hin, die viele Aufgaben zu erledigen hat. Die Konsequenz wiederum ist, dass diese Klasse im Herz des Systems arbeitet und dort auch viel zu tun hat. Das wäre im Prinzip nicht schlecht. Aber im Rahmen der Weiterentwicklung sind solche zentralen Klassen immer schwierig, weil sie den Charakter eines Magneten haben: Jede Änderung am System bedarf einer Änderung in einer beliebigen Klasse und dem Manager oder Handler.

Abb. 1: Ein Microservice für eine Verantwortlichkeit

Abb. 1: Ein Microservice für eine Verantwortlichkeit

Auf Microservices angewendet sollte der Entwickler überlegen, ob der Service, den er gerade entwickelt, genau dieselben Charakteristiken besitzt. Ist er auch eine zentrale Komponente, die magisch alle Änderungen an sich zieht? Dann sollte man das Design der Microservices hinterfragen, ob man einen solchen Service nicht noch weiter aufteilen kann, um einen Flaschenhals im System zu verhindern.

Microservices sind eigentlich laut Definition so zu bauen, dass sie eine Aufgabe im System vollkommen entkoppelt lösen. Man könnte sagen, dass das Single Responsibility Principle das entscheidende Merkmal eines Microservice ist. Dennoch ist dieses Prinzip ein gutes Beispiel dafür, dass sich die Prinzipien von SOLID gut auf Microservices anwenden lassen bzw., dass ein Entwickler sie tunlichst anwenden sollte, um zu einem guten Systemdesign zu gelangen.

Open Closed Principle: Offen und geschlossen gleichzeitig

Das Open Closed Principle empfiehlt, dass eine Klassen offen für Erweiterungen und geschlossen für Modifikationen sein soll. Die Geschlossenheit für Modifikationen beruht darauf, dass eine spätere Änderung das Verhalten einer bestehenden Implementierung nicht verändern soll. Vor allen Dingen nicht, wenn die ursprüngliche Implementierung es nicht gut kapseln kann. Modifikationen widersprechen an sich einer guten Kapselung. Offenheit bezieht sich darauf, dass man eine Klasse an zukünftige Anforderungen anpassen kann. Diese Anpassungen sollten allerdings vorgedacht sein und nicht beliebig durch Modifikationen des bestehenden Sourcecodes passieren.

Microservices sind gut geschützt vor Modifikationen. Denn über das interne Verhalten einer Implementierung wird nichts nach außen propagiert. Bei Microservices wird das Argument, dass die Implementierungstechnologie für einen Microservice frei wählbar ist, oft gleich als Erstes aufgeführt. Denn die Implementierungstechnologie ist durch Remoteschnittstellen verborgen. Heutzutage werden meistens REST-Schnittstellen verwendet, sodass der Microservice nur per HTTP erreichbar ist. Eine Modifikation ist dadurch ausgeschlossen.

Abb. 2: Erweiterungen von Microservices: die Remote-Schnittstelle verwenden

Abb. 2: Erweiterungen von Microservices: die Remote-Schnittstelle verwenden

Die Offenheit gegenüber Erweiterungen ist dagegen ein wenig schwieriger. Denn ein Microservice an sich ist nicht erweiterbar, er exponiert nur eine Remote-Schnittstelle. Diese Remote-Schnittstelle muss für Erweiterungen verwendet werden. Außerdem müssen Schnittstellen vorgesehen werden, durch die man weitere Funktionalität beim Microservice registrieren kann. Das kann dadurch passieren, dass man einen Callback definiert, also bei einem Zustand oder Ereignis innerhalb des Microservice einen anderen Microservice aufrufen lässt, oder dass man bei der Bearbeitung Events wirft, auf die sich ein anderer Microservice registrieren kann.

Welchen Weg man geht, hängt davon ab, ob die Systemarchitektur bereits Events unterstützt oder ob Remote-Aufrufe das Mittel der Wahl sind. Auf jeden Fall zeigt dieses Prinzip wieder, dass beim Design des Microservice überlegt werden sollte, welche Erweiterungen man vorsehen will. Erweiterbarkeit ist wichtig, wenn das System auch darauf ausgelegt sein soll, in einigen Jahren noch verwendet zu werden. Denn neue Anforderungen gibt es immer. Wenn die Microservices auf Erweiterbarkeit ausgelegt sind, hat der Entwickler eines Microservice zumindest schon einen Schritt in diese Richtung getan.

Liskov Substitution Principle: Abwärtskompatibilität gewährleisten

Die ersten beiden Prinzipien gehören zu den bekannteren in der Softwareentwicklung. Eigentlich hat jeder Softwareentwickler schon von ihnen gehört. Das Liskov-Prinzip ist dagegen weniger bekannt. Es besagt, dass eine Klasse gegen eine andere ausgetauscht werden kann, solange die neue Klasse dieselbe Funktionalität bereitstellt wie die zuvor verwendete. In der objektorientierten Programmierung ist das für abgeleitete Klassen gegeben, zumindest wenn die abgeleitete Klasse die Methoden nicht komplett überschreibt. Vereinfacht kann man sagen, dass eine abgeleitete Klasse die Basisklasse an jeder beliebigen Stelle ersetzen kann.

Auf Microservices angewendet bedeutet es, dass sich ein Microservice durch einen anderen nur unter gewissen Rahmenbedingungen austauschen lässt. Bisher hat noch niemand Vererbung auf Services definiert, daher kann man das Konzept der Vererbung von objektorientierten Sprachen nicht direkt übertragen. Dadurch gestaltet es sich etwas schwieriger zu definieren, ob ein Microservice durch einen anderen ersetzt werden kann. Auf jeden Fall ist diese Frage bei der Weiterentwicklung des Systems von entscheidender Bedeutung. Da Microservices im System unabhängig verteilt werden können, d. h. ein Microservice wird aktualisiert und die anderen bemerken es nicht, ist das Liskov-Prinzip wichtig. Die neue Version eines Microservice muss genau dieselbe Funktionalität anbieten wie die alte Version, ansonsten müssten die verwendenden Services angepasst werden.

Abb. 3: Microservices lassen sich nicht einfach untereinander austauschen. Ein neuer Microservice sollte zu seinem älteren Pedant aber immer abwärtskompatibel sein

Abb. 3: Microservices lassen sich nicht einfach untereinander austauschen. Ein neuer Microservice sollte zu seinem älteren Pedant aber immer abwärtskompatibel sein

Eine neue Version eines Microservice muss sich bezüglich Schnittstelle und Verhalten genau wie die alte verhalten. Das ist die perfekte Anwendung des Liskov-Prinzips. Meistens wird diese Forderung als Abwärtskompatibilität bezeichnet. Um die Evolution eines Systems zu erlauben, sollte der Verantwortliche eines Microservice darauf achten, dass sein Service auch wirklich immer kompatibel bleibt.

Eine gute Strategie, die sich in der objektorientierten Programmierung durchgesetzt hat, besteht in einer hohen Testabdeckung. Für die verschiedenen Sprachen gibt es Unit-Test-Frameworks wie JUnit. Für Microservices kommt man nicht umhin, den Microservice an sich zu testen. Man kann natürlich die Bestandteile des Microservice auch separat testen. Aber da Kompatibilität für andere Microservices für die Remote-Schnittstellen garantiert werden muss, kommt man nicht um Unit Tests für einen Microservice herum.

Interface Segregation Principle: Schnittstellen klein halten

Das Interface-Segregation-Prinzip dient dazu, zu große Interfaces aufzuteilen. Ein Verwender soll eine Schnittstelle angeboten bekommen, die genau auf seine Anforderungen passt und nicht mehr enthält. In der objektorientierten Programmierung ist dieses Prinzip bei der Definition von Interfaces wichtig, die von Klassen implementiert werden. Sobald ein Verwender eine Instanz bekommt, wird nur über die Schnittstelle auf das Objekt zugegriffen. Dadurch kann und muss der Verwender nicht mehr tun, als seine Anforderung definiert.

Wenn man dieses Prinzip auf Microservices anwendet, kann man das auf zwei unterschiedlichen Wegen tun: durch die Definition der Granularität des Microservice und das Exponieren von Schnittstellen. Es ist gibt eine große Diskussion darüber, wie groß ein Microservice sein soll. Die extreme Ansicht ist, dass der Microservice auch nur einige hundert Zeilen Sourcecode enthalten kann. Wenn man das Interface-Segregation-Prinzip genauso rigoros auslegt, besitzt der Microservice auch nur genau die passende Schnittstelle und ist so klein, wie es die Anforderung vorschreibt.

Das Single-Responsibility-Prinzip empfiehlt zwar, dass ein Service nur eine Funktionalität anbietet, aber das besagt nicht notwendigerweise, dass es auch technisch nur eine einzige Schnittstelle geben muss. Der Microservice könnte auf technischer Ebene einfach mehrere Schnittstellen exponieren, die für den jeweiligen Verwender geeignet sind.

An dieser Stelle wird eine Unterscheidung von Microservices zu SOA wichtig. Da beide Konzepte auf Services basieren, gibt es notwendigerweise viele Gemeinsamkeiten. Ein Hauptunterschied besteht aber darin, dass ein System, das Services nach SOA exponiert, auch durchaus unterschiedliche Services anbietet. Es gilt daher für das SOA-System nicht das Single-Responsibility-Prinzip. Ein Microservice ist ja eine eigenständig verteilbare Einheit, die einer eindeutigen Aufgabe dient. Wenn man diese Überlegungen mit in Betracht zieht, existieren einige Begrenzungen, um einen Microservice zu definieren. Nach dem Interface-Segregation-Prinzip sollte die Schnittstelle nicht so definiert sein, dass alles aufgenommen wird. Es bietet sich an, lieber einen weiteren technischen Endpunkt anzulegen, um andere Verwender zufriedenzustellen.

Abb. 4: Mehrere Endpunkte für einen Microservice anlegen

Abb. 4: Mehrere Endpunkte für einen Microservice anlegen

Darüber hinaus sollte der Microservice aber auch keine zusätzlichen Aufgaben erfüllen, sodass er nur noch ein Utility-Service mit möglichst viel Funktionalität wäre.

In der normalen Programmierung mit Interfaces und Klassen wird das Interface-Segregation-Prinzip auch meistens angewendet, um Refactoring zu motivieren. Wenn ein Anbieter einer Funktionalität merkt, dass bestimmte Verwender nur einen Teil der Schnittstelle verwenden und andere wiederum einen anderen, ist das normalerweise der Anfang eines Refactorings. Das sollte in allen Projekten von Zeit zu Zeit passieren, um die Codebasis sauber zu halten.

Bei Microservices wird es jetzt schwierig. Denn ein Microservice ist eine verteilbare Einheit. Wenn das Refactoring diese Granularität ändert, verändert sich auch die Zuordnung von Funktionalität. Dieser Nachteil wird gerade bei der Betrachtung der Schnittstellen, die ein Microservice exponiert, deutlich. Denn im Vergleich zu normaler Programmierung innerhalb eines Systems sind Änderungen viel komplizierter umzusetzen.

Dependency Inversion Principle: Nur mit Schnittstellen arbeiten

In den alten Zeiten der Programmierung wurden Methoden auf Objekten aufgerufen, sodass der Aufrufer eine Abhängigkeit zum Aufgerufenen erhielt. Das führte zu großen und verbundenen Abhängigkeitsgraphen, die meistens sogar zyklische Abhängigkeiten enthalten. Die Erfahrung hat gezeigt, dass es schwierig wurde, solche Systeme weiterzuentwickeln. Eine Änderung an einer Stelle bedingte direkt Änderungen an vielen anderen Stellen. Da die Komplexität immer weiter stieg, konnten die Entwickler das System nicht mehr unter Kontrolle halten und scheuten durch die Seiteneffekte ihrer Änderungen meistens jede Weiterentwicklung.

Das Dependency-Inversion-Prinzip besagt, dass der Verwender ein aufrufendes Objekt nicht mehr direkt referenziert, sondern nur noch über die Schnittstelle. Der Verwender definiert also, welche Funktionalität er benötigt und damit auch die Schnittstelle. Die Abhängigkeit ist somit umgedreht vom Anbieter zum Verwender.

Im Laufe der Zeit wurden die Implementierungen nicht nur angepasst, sodass diese Abstraktion zwischen Verwender und Anbieter eingeführt wurde. Es wurden auch Frameworks entwickelt, um solche Abhängigkeiten besser verwalten zu können. Das bekannteste Beispiel ist das Spring Framework, das von Beginn an Dependency Injection zum Prinzip erhob. Die Objekte werden implementiert und alle externen Abhängigkeiten werden nur über Schnittstellen aufgerufen. Das Framework ist jetzt dafür zuständig, dass in den Aufrufer eine Instanz eines Objekts injiziert wird, das die Schnittstelle erfüllt.

Im letzten Jahrzehnt entstanden mehrere solcher Frameworks. Dies führte dazu, dass sich die Kopplung von Objekten reduzieren ließ. Um zu verdeutlichen, welche Vorteile dieser Weg bietet, sei nur an die Teststrategien erinnert. Ließen sich die Systeme vorher nur komplett testen, weil alle Klassen implementiert und vorhanden sein mussten, so war es durch Dependency Injection möglich, eine einfache Implementierung, einen Mock, anzubieten. So konnte der Verwender ohne großen Aufwand getestet werden. Anstelle eines riesigen Tests eines Systems gab es jetzt viele kleine Tests.

Die interessante Frage ist, was das für Microservices bedeutet. Es ist offensichtlich, dass eine Architektur, die auf Microservices basiert, auf keinen Fall zu eng gekoppelt sein darf, sodass ein System nur weiterentwickelt oder getestet werden kann, wenn alle Microservices vorhanden sind und laufen. Die direkte Folge ist, dass Microservices nur die Schnittstellen verwenden dürfen und auf keinen Fall Interna der Implementierung des Microservice voraussetzen dürfen. Durch die Remote-Grenzen zwischen den Microservices ist das eigentlich eine einfach umzusetzende Forderung. Denn die Schnittstellen müssen sehr genau definiert sein.

Abb. 5: Abhängigkeiten zwischen Microservices setzen

Abb. 5: Abhängigkeiten zwischen Microservices setzen

Ein anderer interessanter Aspekt ist, dass Frameworks das Injizieren von Abhängigkeiten übernommen haben, um die konkreten Objekte nicht mehr mit der Abhängigkeitsverwaltung zu belasten. Der Lebenszyklus von Aufrufer und Aufgerufenem wurde unterbrochen. Auf Microservices angewendet, bedeutet das, dass ein Microservice die Instanz eines anderen verwendeten Microservice durch ein Framework erhalten muss und auf keinen Fall selbst dafür verantwortlich sein darf, eine Instanz zu erzeugen.

Wenn man sich die aktuellen Beispiele für Microservices-Architekturen anschaut, dann wird dieser Weg gerade beschritten. Netflix wird meist als Vorbild für eine Microservices-Architektur verstanden. Unter den Open-Source-Projekten, die Netflix an die Community übergeben hat, gibt es eine Komponente, die für die Verwaltung von Services verantwortlich ist: Eureka. Eureka bietet eine Service-Registry, bei der sich jeder Service registrieren muss. Verwender können sich Referenzen auf registrierte Services besorgen. Das ist keine automatische Registrierung und Injizierung von Instanzen durch ein Framework, aber zumindest gibt es eine Komponente in der Architektur, die für diese Aufgabe verantwortlich ist.

Darüber hinaus gibt es auch andere Wege, um die Konfiguration innerhalb einer verteilten Architektur zu bewerkstelligen. Bereits seit etlichen Jahren existiert zum Beispiel ZooKeeper, ein Framework, um Konfigurationen in verteilten Umgebungen bereitzustellen. Viele Frameworks haben zusätzliche Schichten auf ZooKeeper gebaut, um Konfigurationen zwischen den verschiedenen Knoten zu verteilen: zum Beispiel Archaius und Distributed OSGi. Archaius ist auch Teil der Netflix-Bibliotheken und verwendet ZooKeeper zur Verteilung der Konfigurationen. Distributed OSGi ist ein Modulsystem für Java. Da auch schon die letzten Jahre über die Anforderung aufkam, über mehrere JVMs hinweg zu kommunizieren, wurde Distributed OSGi ins Leben gerufen. Für die Konfiguration verwendet es ZooKeeper.

Das waren nur ein paar Beispiele, um zu zeigen, dass die Verwaltung von Abhängigkeiten schon die letzten Jahre über ein großes Problem war, und mit dem Aufkommen von Microservices, die Verteilung zum Prinzip erheben, wird es zu einem essenziellen Problem. Um das aktuelle Chaos komplett zu machen, gibt es in jüngster Zeit andere Möglichkeiten, Konfigurationen in verschiedenen Laufzeiten anzusprechen. Diese Frameworks sprechen direkt davon, Microservices gut zu unterstützen, im Gegensatz zu den Frameworks wie ZooKeeper und Distributed OSGi. Beispiele hierfür sind Spring Cloud Config und Docker Compose. Spring Cloud Config ist ein Teil der vielen Projekte, die um Spring herum entstanden sind, und nimmt sich der Aufgabe an, Konfigurationen für verschiedene Laufzeiten über HTTP verfügbar zu machen. Bei Docker Composer denken die meisten an Docker als Containertechnologie, um schnell Laufzeiten hoch- und runterzufahren. Docker Compose adressiert das Problem, dass verschiedene Laufzeiten in einem Docker Host hochgefahren werden und auch bereits beim Start konfiguriert werden. Die Abhängigkeiten setzt also wirklich bereits das Framework beim Starten.

Wie man sieht, ist es gerade bei Microservices ein Problem, die Verbindung zwischen den einzelnen Teilnehmern hinzukriegen. Da es sich im Moment noch nicht abzeichnet, welches Framework sich am Ende durchsetzt, bleibt nur die Empfehlung, sich beim Aufsetzen eines Microservices-Systems am Anfang der Definition Gedanken zu machen, wie die Microservices ihre Abhängigkeiten bekommen können. Der schlechteste Weg wäre, dass die Abhängigkeiten hart implementiert sind, also zum Beispiel ein URL. Bei URLs gäbe es zwar wieder die Möglichkeit, über DNS eine Indirektion einzurichten, aber DNS zu konfigurieren, ist nicht unbedingt der einfachste Weg. Für externe Abhängigkeiten, die seit Jahren stabil sind und einen stabilen URL exponieren, ist das natürlich auch ein gangbarer Weg.

Abb. 6: Die Prinzipien von SOLID lassen sich auch auf Microservices anwenden

Abb. 6: Die Prinzipien von SOLID lassen sich auch auf Microservices anwenden

Was kann man daraus lernen?

Die Prinzipien von SOLID lassen sich auch auf Microservices anwenden. In der ursprünglichen Definition der Prinzipien ging es um Objekte, doch genau dieselben Fragen, die bei jedem einzelnen Prinzip im Fokus stehen, sind auch bei Microservices wichtig. Die Liste der SOLID-Prinzipien kann als gute Checkliste für das Design der Microservices dienen. Die Fragestellungen lassen sich genauso anwenden. Die normale Laufzeit für Objekte, eine JVM- oder eine .NET-Laufzeit, wurde eigentlich nur durch ein Netzwerk ausgetauscht. Durch die Anforderung an Microservices, dass sie separat verteilt werden können, handelt es sich um ein dynamisches System. Denn Microservices lassen sich hinzufügen, ändern oder herunterfahren. Dadurch werden die Prinzipien für eine gute Architektur noch wichtiger, denn Fehler würden dazu führen, dass manche der Hoffnungen, die mit Microservices verbunden sind, nicht eingehalten werden können.

Aufmacherbild: Steel beams via Shutterstock / Urheberrecht: Nattakit Jeerapatmaitree

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

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: