Shared Code in Microservices

1 Kommentar

Microservices sind schon lange kein Hype-Thema mehr. Unzählige Blogartikel, Bücher, Best Practices, Tweets und War Stories aus konkreten Projekten zeugen von einem gelebten Architektur-Stil. Es gibt kaum eine Frage, die nicht bereits mehrfach von allen Seiten beleuchtet wurde. Angefangen beim grundsätzlichen Konzept und dem fachlichen Zuschnitt bis hin zu Themen wie Team- und Kommunikationsstruktur, Deployment, Auffindbarkeit von Diensten, Logging und Monitoring gibt es genügend Literatur, Anleitungen, Frameworks und Werkzeuge. Dieser Artikel ist auch keine weitere umfassende Einführung in Microservices, auch wenn einige Kernprinzipien angerissen werden.

Es geht um ein Thema, welches in zuverlässiger Regelmäßigkeit immer wieder hochschlägt und das den Konferenz-Experten gleichermaßen beschäftigt wie Entwickler beim verbalen Handgemenge in der Kaffeeküche: Darf man Code in Microservice-Projekten wiederverwenden oder nicht? Muss man das Rad stets neu erfinden oder Code kopieren, um Microservices gemäß der reinen Lehre zu implementieren? Ist Shared Code, ganz gleich in welcher Form, ein knallharter Verstoß gegen das Isolationsprinzip? Oder ist ein konsequent eingehaltenes „Share Nothing“-Gebot nicht vielmehr eine Verletzung des DRY-Prinzips („Don’t Repeat Yourself“)?

Lose Koppelung und Isolation

Microservices sollten kein technischer Selbstzweck sein. Eines des Kernziele bei der Einführung von Microservices ist die weitgehende Entkoppelung von Komponenten, die unter technologischer Autonomie konzipiert und umgesetzt werden und über einheitliche Schnittstellen kommunizieren. Zweck dabei ist die bessere Beherrschbarkeit von Komplexität, ein höherer Grad an Isolation während der Anwendungslaufzeit sowie der Gedanke, Geschäftsideen und Technologien schneller zu verproben und auch wieder wegzuwerfen falls sie sich als untauglich erweisen.

Dabei implementiert ein Microservice typischerweise den Geschäftsprozess einer Fachdomäne, er hält und ändert seine eigenen lokal gültigen Daten, ist für deren Verwaltung selbst verantwortlich und exponiert explizite Schnittstellen nach außen. Vereinfacht gesagt: Er ist „angemessen klein“ und auf eine bestimmte Aufgabe fokussiert. Aus dem strengen Modularisierungskonzept und der Aufteilung der Teams auf Domänen-Komponenten [1] resultieren noch einige weitere Vorteile:

  • Größere technologische Unabhängigkeit, da es keine direkten Abhängigkeiten zu anderen Microservices gibt, im Regelfall also auch keinen Shared Code
  • Wahlfreiheit der Technologie (Sprachen, Frameworks), dadurch ein größeres Team-Commitment [2]
  • Unabhängiges Release und Deployment der Services, zumindest bei Wahrung der Abwärtskompatibilität von Schnittstellen
  • Autonome Teams, schnelle lokale Entscheidungen bei fachlichen und mikroarchitekturellen, technischen Fragen
  • Geringerer Abstimmungsaufwand unter den Teams
  • Separate Lebenszyklen
  • Bedarfsgerechte Skalierung

Doch nicht selten besteht auch in Projektteams im Microservice-Umfeld der Wunsch nach einer gemeinsamen Verwendung von Datenbankschemata, Data Sources, Code für den Zugriff auf häufig benutzte Objekte einer Domäne oder bereits existierender Funktionalität. Es wirken zwei Kräfte mit unterschiedlicher Richtung an einem Punkt: Die „maximale generische Wiederverwendbarkeit“ auf der einen Seite, das Konzept der Unabhängigkeit und Isolation auf der anderen.

Das Heilsversprechen der Wiederverwendbarkeit

Der stark ausgeprägte Wunsch nach Wiederverwendbarkeit verwundert nicht, immerhin war das DRY-Prinzip jahrzehntelang das Mantra der Softwareentwicklung. Und auch heute noch wird Entwicklern eingetrichtert, Redundanz ist die schlechteste aller Entwickler-Schurkereien, schließlich wäre es sinnvoller, Code zu „erben“ anstatt zu schreiben. Was wiederverwendet werden kann, muss nicht noch einmal geschrieben werden und senkt damit Kosten. Wiederverwendbarer Code sollte dabei idealerweise folgende Anforderungen erfüllen:

  • Shared Code muss auf viele Fälle passen
  • Geiteilter Code muss eine hohe Qualität aufweisen
  • Gemeinsam genutzter Code sollte gut dokumentiert sein

Nicht selten wird an der Stelle argumentiert, durch Offenlegung von solchem Code in einem Open-Source-Projekt werden die oben beschriebenen Anforderungen erfüllt, da externe Beiträge und Bugreports zu einer Verbesserung des Codes führen. Schließlich gibt es viele erfolgreiche Projekte wie Apache Commons oder Netflix Hystrix, die gemeinsam genutzte Komponenten bereitstellen. Die Veröffentlichung als Open-Source-Bibliothek ist dabei tatsächlich sinnvoll, schließlich möchte man keinen Code nutzen, den man nicht auch bereit wäre, an externe Nutzer zu verteilen. Doch auch wenn dadurch erschwert wird, dass zu spezifische Dinge in den Shared Code gelangen, bleibt eine entscheidende Frage unbeantwortet: Wo genau ist die Grenze zwischen sinnvollem Code Sharing und übertriebener Generalisierung?

Antworten hierauf liefert das Konzept des Bounded Context aus Domain Driven Design, eine Herangehensweise an die Modellierung von Software, die sehr stark die Fachlichkeit einer Anwendungsdomäne fokussiert.

Bounded Context

Der Drang, alles zu generalisieren, führte in vielen Projekten der Vergangenheit zu globalen Datenmodellen und einer falschen fachlichen Abstraktion. Statt versprochener Produktivitätsschübe zieht dieses Vorgehen eine Verlangsamung im Entwicklungsprozess nach sich. Der Grund hierfür liegt auf der Hand: Eine fachlich falsche Generalisierung führt zu einem hohen Koordinationsaufwand, da sich viele beteiligte Entwickler (oder schlimmer: mehrere Organisationseinheiten) untereinander abstimmen müssen. Schließlich muss entschieden werden, welcher Code in einer gemeinsam genutzten Komponente landet und wie dessen Qualität gesichert wird. Hat man es dann noch mit einer ganzen Abteilung zu tun, die sich „querschnittlichen Aufgaben“ widmet, ist im Extremfall nicht mehr die Fachabteilung mit Kontakt zum Kunden der Treiber, sondern der in Organisationsform gegossene Gralshüter der Wiederwendbarkeit [3].

Bei Domain Driven Design hingegen steht das Konzept des Bounded Context an zentraler Stelle. Jede Fachdomäne besteht in der Regel aus mehreren Bounded Contexts. Solch eine „Kontextgrenze“ beschreibt den Gültigkeitsbereich eines fachlichen Modells.

Für ein besseres Verständnis dieser Definition soll ein einfaches Beispiel herhalten: Der Begriff Flug aus der Luftfahrtindustrie hat, je nach Kontext, eine eigene Bedeutung. Aus Sicht eines Passagiers ist ein Flug der Personentransport zu einem Zielflughafen, entweder in Form eines Direktflugs oder mit Zwischenstopp. Aus Sicht des Bordpersonals besteht ein Flug aus Start und Landung. Und Techniker des Instandhaltungsbetriebs am Flughafen betrachten einen Flug aus der Perspektive der Flugzeugwartung. Je nach Kontext wird unter diesem Begriff etwas anderes verstanden.

Different views of the term flight

Die Modellierung eines Fluges in Form einer generischen Flight-Klasse würde nur zur Verwirrung führen. Vielmehr sollte das, was im Einzelfall unter einem Flug verstanden wird, in einem eigenen Bounded Context modelliert werden. Der Versuch, Gemeinsamkeiten in eine Elternklasse auszulagern und einzelne Ausprägungen in Kindklassen zu implementieren, führt zwangsläufig zu einer starken Koppelung [4].

Als Faustregeln können wir uns merken:

  • Duplizierung ist besser als falsche Abstraktion
  • Redundanzen sollten bewusst in Kauf genommen werden, da diese besser sind als starke Koppelung
  • Jedes Verhalten und Wissen in einer Domäne sollte eine eindeutige, maßgebende Repräsentation innerhalb eines Systems besitzen

Der Letzte Merksatz bedeutet in der Praxis: Keine Wiederverwendung von Business-Logik über mehrere Bounded Contexts hinweg. Code Reuse bei fachlichem Code innerhalb eines Bounded Context hingegen ist unkritisch.

Shared Domain Model

Es gibt jedoch durchaus Anwendungsfälle, wo Bereiche des Domänenmodells zwischen verschiedenen Bounded Contexts geteilt werden können. Man denke dabei zum Beispiel an Anwendungsfälle wie Session- oder Authentisierungslogik. Anstatt diese immer wieder neu zu implementieren und Inkonsistenzen in Kauf zu nehmen, ist es in solchen Fällen denkbar, dass Produzent (Service Supplier) und Konsument (Service Consumer) sich diese gemeinsamen Strukturen teilen. Dies kann entweder mit Hilfe eines Anti-Corruption-Layer bewerkstelligt werden, das im Konsumenten ein Mapping auf das eigene Domänenmodell bereitstellt. Oder das Domänenmodell des Produzenten wird direkt im Konsumenten verwendet. In diesem Fall spricht man auch von Shared Kernel. Beide Vorgehensweisen können selbst dann angewandt werden, wenn der Konsument nicht alle Attribute des vom Produzenten bereitgestellten Domänenmodells benötigt.

Diese Verfahrensweise widerspricht den weiter oben genannten Prinzipien der losen Koppelung und Isolation, denn insbesondere bei der Variante Shared Kernel geht die Unabhängigkeit von Microservices verloren. Dennoch gibt es Szenarien wie die weiter oben beschriebenen, wo sich solch ein Vorgehen anbietet. Wichtig ist dabei ein möglichst stabiles Shared Domain Model, dessen Änderungen zumindest solange abwärtskompatibel bleiben sollten, bis alle Konsumenten selbst auf den neuesten Stand gebracht worden sind.

Entscheidend dabei ist vor allem eine gesunde Kommunikationskultur zwischen den betreffenden Teams. Und: Dieses Vorgehen ist ein Trade-off und bedarf einer nutzungsgerechten Abwägung. Es sollte nicht als Rechtfertigung für ein universelles Datenmodell in einer komplexen Anwendungslandschaft herangezogen werden.

Cross-Cutting Concerns

Wie verhält es sich nun mit Bibliotheken wie Apache Commons oder Google Guava? Die verkürzte Antwort lautet: Shared Libraries für technische Belange wie beispielsweise Logging, Monitoring, Tracing, String-Manipulationen, Collections oder Abstraktions-Layer für Infrastruktur-Zugriffe sind Cross-Cutting Concerns, da diese nicht vom Kontext einer Domäne abhängig sind. Es ist völlig in Ordnung, Bibliotheken gemeinsam zu verwenden, die nicht-fachliche Aspekte betreffen [5].

Dependency Hell

Diese Antwort adressiert jedoch nicht das Problem, dass solche Bibliotheken oft den Nachteil vieler transitiver Abhängigkeiten besitzen. Es ist nur eine Frage der Zeit, bis man sich Versionskonflikte einfängt und nicht selten findet man sich am Ende in der berüchtigten Dependency Hell wieder.

Eine Möglichkeit, um das Problem mit der Dependency Hell zu vermeiden, ist die Bereitstellung sehr schlanker Bibliotheken für klar umrissene Aufgaben mit wenigen oder gar keinen Abhängigkeiten. Solche Bibliotheken stehen in starkem Kontrast zu General-Purpose-Libraries. Eine Alternative dazu ist die Verwendung von Projekt-Templates, die ein Grundgerüst mit Basis-Funktionalitäten bereitstellen. Nach dem Top-Down-Ansatz kann dieses Template in jedem Service beliebig erweitert werden. Eric Evans geht in seinem Buch über Domain Driven Design sogar so weit, dass er dieses Vorgehen auf das Konzept des Shared Kernel ausweitet: Verschiedene Teams verändern ihre eigene Kopie des Shared Kernels und in regelmäßigen Intervallen werden die verschiedenen Änderungen wieder zusammen geführt. Zugegebenermaßen behagt aber nicht jedem diese Idee des kontinuierlichen Copy & Paste und der erneuten Zusammenführung.

Deployment-Abhängigkeiten

Etwas komplexer gestaltet sich folgendes Szenario, das aus einem aktuellen Projekt stammt: Eine Library für Health-Checks prüft unter anderem die Verbindung zu Elasticsearch sowie die Existenz bestimmter Indizes und Aliase. Unter der Haube verwendet diese Bibliothek eine Open-Source-Library, die einem Client für den Verbindungsaufbau zu Elasticsearch bereitgestellt. Im Zuge einer User Story wurden der Health-Check-Bibliothek neue Features hinzugefügt. Gleichzeitig erfolgte ein Upgrade auf eine neue Major-Version [6] des Elasticsearch-Clients. Dadurch mussten zusätzliche Anpassungen vorgenommen werden.

Version conflict

Services, die die neuen Health-Checks verwenden, nutzen in der Regel aber denselben Elasticsearch-Client auch im eigenen Code, nur in der älteren Version. In den betreffenden Services mussten also ähnliche Anpassungen erfolgen, dies erhöhte den Aufwand zusätzlich. In diesem Fall bieten sich zwei Lösungen an:

  • Ersetzung des Elasticsearch-Clients in den Health-Checks durch eine leichtgewichtige Lösung (Prüfung der Verbindung usw. braucht nicht alle Features eines umfassenden Elasticsearch-Clients)
  • (Temporäre) Bereitstellung der Health-Check-Library in zwei Versionen: Eine mit der alten und eine mit der neuen Version des Elasticsearch-Clients

Beide Lösungsansätze wirken auf den ersten Blick wie eine überflüssige Schererei. Doch bei mehreren Dutzend Services verhindert dieser Aufwand großflächige „Zwangs-Upgrades“, die Inklusion der neuen Health-Checks kann schrittweise erfolgen.

Angenommen es wird eine gemeinsam genutzte Bibliothek zur Verwendung eines zentralen Services bereitgestellt. Finden Änderungen am Service statt, müssten alle Microservices eine aktualisierte Version dieser Library nutzen. Wenn die infrage kommenden Services von verschiedenen Teams betreut werden, ist dies mit einem nicht unerheblichen Koordinierungsaufwand beim Redeployment verbunden.

Abwärtskompatible Änderungen des Services hingegen führen zu keinen Deployment-Abhängigkeiten, da ältere Versionen dieser Bibliothek weiter unterstützt werden. Auch die temporäre Bereitstellung von zwei verschiedenen Versionen des Services einschließlich zweier verschiedener Bibliotheksversionen ist denkbar. Die ältere Variante wird dann abgeschaltet, sobald gesichert ist, dass kein Client mehr von dieser abhängig ist.

Generell sollte bei externen Abhängigkeiten in einem Microservice immer die Wahlfreiheit der konkret verwendeten Version einer Bibliothek sichergestellt werden, um Abhängigkeiten dieser Art zu vermeiden.

Shared Infrastructure as Code

Im Sinne einer gesunden, produktiven Teamautonomie und zur Vermeidung von Infrastruktur-Monolithen wird empfohlen, den Bereich Infrastructure as Code nach Makroarchitektur- und Mikroarchitektur-Gesichtspunkten aufzuteilen. Innerhalb der Begrenzung eines Microservices sollten auch sämtliche Infrastruktur-Definitionen erfolgen, die direkt zu dessen Domäne gehören. Ein Beispiel zur Verdeutlichung: Ein Microservice, der Daten ingestiert und im Anschluss in einer Message Queue ablegt, ist ein Kandidat für die Ablage der Infrastruktur-Definition des Topics der betreffenden Message Queue. Die Subscriptions der Queue hingegen sollten in Services definiert werden, welche Daten von diesem Topic konsumieren.

Alle Infrastruktur-Definitionen, die sich nicht eindeutig einem Service zuordnen lassen, sind Aspiranten für die Makro-Infrastruktur (oder auch Makro-Stack). Dieser Stack enthält also alle Cross-Cutting Aspekte und besteht idealerweise ausschließlich aus Definitionen, welche sich selten ändern. Dazu gehören typischerweise auch die Netzwerk- und Security-Infrastruktur [7]. Die Aufteilung in verschiedene Service-Stacks (Mikro-Stacks) und einen Makro-Stack kann beispielsweise über die Modularisierungs- und Inklusions-Konzepte von Terraform und CloudFormation umgesetzt werden.

Zusammenfassung

Shared Code bzw. Code Reuse in Microservices führt zu Abhängigkeiten, welche die Kernidee von diesem Architekturstil ad absurdum führen können. Deshalb sollte man sich an der Idee des Bounded Context des Domain Driven Design orientieren und zwischen fachlichen und nicht-fachlichen Aspekten unterscheiden. Letztere können in Shared Code überführt werden. Es wird jedoch empfohlen, diesen schlank zu halten und bei Änderungen für entsprechende Abwärtskompatibilität zu sorgen, um Deployment-Abhängigkeiten zu vermeiden. Infrastructure as Code ist unter ähnlichen Gesichtspunkten zu bewerten wie Programmcode. Das Pattern Shared Domain Model sollte mit Bedacht angewandt werden und erfordert auch eine gesunde Kommunikationskultur zwischen den Teams.

 

[1] Vgl. http://www.informit.com/articles/article.aspx?p=2738465&seqNum=2
[2] Diese Wahlfreiheit wird in der Praxis oft eingeschränkt, um einen Technologie-Zoo zu vermeiden.
[3] Dazu kommt, dass die bei der Fokussierung auf Wiederverwendbarkeit erreichte Flexibilität gleichzeitig zu einer Vergrößerung der Komplexität führt. Dabei spricht man auch vom Use/Reuse-Paradoxon: http://techdistrict.kirkk.com/2009/10/07/the-usereuse-paradox/
[4] Die Aussage gilt im Übrigen nicht nur für Microservices-Projekte, sondern generell dort, wo man sinnvoll modularisieren will. Zudem wird man bei Änderungen der Basis-Klasse schnell gegen das Liskovsche Substitutionsprinzip verstoßen: http://newsight.de/2015/01/07/das-liskov-substitution-principle
[5] Wir beschränken uns in diesem Szenario auf Java, doch gelten die hier gemachten Aussagen auch für andere Sprachen.
[6] Semantische Versionierung: https://semver.org/
[7] Vgl. https://www.infoq.com/news/2018/06/cloud-native-continuous-delivery

Kommentare

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.