Beliebte Suchanfragen

Cloud Native

DevOps

IT-Security

Agile Methoden

Java

//

Shared Code in Microservices

16.7.2018 | 10 Minuten Lesezeit

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 deshalb auch keine weitere umfassende Einführung in Microservices, auch wenn einige Kernprinzipien angerissen werden.

Vielmehr geht es 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 gilt auch hier vielmehr „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 das Ziel, 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, d. h. 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. Er ist also „angemessen klein“ und fokussiert auf eine bestimmte Aufgabe. Aus diesem strengen Modularisierungskonzept und der Aufteilung der Teams auf Domänen-Komponenten [1] resultieren noch einige weitere Vorteile:

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

Doch besteht auch in Microservice-Projekten nicht selten der Wunsch nach gemeinsamer 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:

  • Muss auf viele Fälle passen
  • Hohe Qualität
  • Gute Dokumentation

Nicht selten wird an der Stelle argumentiert, durch Offenlegung von solchem Code in einem Open-Source-Projekt werden die oben beschriebenen Anforderungen automatisch erfüllt, da externe Beiträge und Bugreports zu einer Verbesserung des Codes führen. 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 solch ein gemeinsam genutztes Projekt 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 (DDD), 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 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 Organisation zu tun, die sich „querschnittlichen Aufgaben“ widmet, ist im Extremfall nicht mehr die Fachabteilung mit Kontakt zum Kunden der Treiber, sondern die Querschnittseinheit als 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.

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 wenn die Alternative dazu eine starke Koppelung ist
  • 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 Anwendungsfälle, in denen Bereiche des Domänenmodells zwischen verschiedenen Bounded Contexts geteilt werden müssen. Anstelle von Mehrfachimplementierungen mit Inkonsistenzen ist es in solchen Fällen denkbar, dass sich Produzent (Service Supplier) und Konsument (Service Consumer) gemeinsame Strukturen teilen. Dabei kann z. B. das Domänenmodell des Produzenten direkt im Konsumenten verwendet werden (Shared Kernel). Interaktionen und Abhängigkeiten zu anderen Microservices können aber auch mit Hilfe eines Anti-Corruption-Layer bewerkstelligt werden, das im Konsumenten ein Mapping auf das eigene Domänenmodell bereitstellt. Beide Vorgehensweisen sind Teil der beim DDD beschriebenen Context Maps und können selbst dann angewendet werden, wenn der Konsument nicht alle Attribute des vom Produzenten bereitgestellten Domänenmodells benötigt.

Diese Verfahrensweise widerspricht selbstverständlich den weiter oben genannten Prinzipien der losen Koppelung und Isolation, denn insbesondere bei der Variante Shared Kernel geht die Unabhängigkeit zwischen Microservices verloren. 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. Dennoch gibt es Szenarien wie Session- oder Authentisierungslogik, wo sich solch ein Vorgehen anbietet.

Wichtig sind dabei zwei Dinge: Eine gesunde Kommunikationskultur zwischen den betreffenden Teams und ein möglichst stabiles Shared Domain Model, dessen Änderungen zumindest solange abwärtskompatibel sein sollten, bis alle Konsumenten auf den neuesten Stand gebracht worden sind.

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 realen Projekt stammt: Eine Library für Health-Checks prüft unter anderem die Verbindung zu Elasticsearch sowie die Existenz bestimmter Indizes und Aliases. Unter der Haube verwendet diese Bibliothek eine Open-Source-Library, die einen Elasticsearch-Client bereitstellt. Im Kontext 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.

Services, die die neuen Health-Checks verwenden, nutzen in unserem konkreten Beispiel aber denselben Elasticsearch-Client auch im eigenen (fachlichen) Code, nur in einer älteren Version. In den betreffenden Services mussten also ähnliche Anpassungen erfolgen, dies erhöhte den Aufwand immens. 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.

In eine ähnliche Richtung geht folgendes Szenario: Angenommen es wird eine gemeinsam genutzte Bibliothek zur Verwendung eines zentralen Services bereitgestellt. Finden Änderungen an diesem 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 somit ä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. Das Pattern Shared Domain Model sollte mit Bedacht angewandt werden und erfordert auch eine gesunde Kommunikationskultur zwischen den Teams. Infrastructure as Code ist unter ähnlichen Gesichtspunkten zu bewerten wie Programmcode.

[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

Beitrag teilen

Gefällt mir

3

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.