Kolumne

EnterpriseTales: Wenn der Client das API spezifiziert

Arne Limburg
Funktionale Programmierung

Wie teste ich eigentlich das Zusammenspiel in meiner Microservices-Landschaft? Wie stelle ich sicher, dass jeweils die richtige Version eines Service auf der richtigen Stage deployt ist? Wie behalte ich die Abhängigkeiten meiner Microservices im Blick? Und wie stelle ich bei der Weiterentwicklung meiner APIs sicher, dass alle Clients weiterhin funktionieren? Wie kann ich sicher sein, dass eine alte Version einer Schnittstelle nicht mehr benötigt wird und deshalb entfernt werden kann? Consumer-driven Contracts versprechen eine Lösung für alle diese Fragen, kommen aber mit einer erheblichen Komplexität und haben ihrerseits Grenzen.

Das Management der Abhängigkeiten aller Services in einer Microservices-Landschaft stellt eine der größten Herausforderungen dieses Architekturansatzes dar. Fragen danach, welcher Service welche Schnittstelle in welcher Version verwendet, müssen beantwortet werden. Weitgehende Abwärtskompatibilität der Schnittstellen ist deshalb vor Allem bei Public APIs Pflicht.

Bei den internen APIs, die lediglich genutzt werden, damit die Microservices untereinander kommunizieren können, kann die Sache etwas entspannter angegangen werden: Wenn ein Teil eines API von keinem Client mehr benutzt wird, kann er abgeschaltet werden, egal ob es sich dabei um einen Breaking Change handelt oder nicht. Voraussetzung dafür ist, eine Möglichkeit zu suchen, die herausfindet, ob ein (internes) API noch verwendet wird oder nicht. Ein möglicher Lösungsansatz sind Consumer-driven Contracts.

Entfernen alter APIs

Normalerweise ist es so, dass der Anbieter eines API es spezifiziert und dokumentiert, um seinen Clients die Möglichkeit zu bieten, das API auf eine definierte Art zu benutzen. Im Kontext von Consumer-driven Contracts wird das als Provider Contract bezeichnet.

In der Praxis ist es allerdings häufig so, dass es keinen Client gibt, der die gesamte Breite der angebotenen Schnittstelle verwendet. Tatsächlich nutzt jeder Client in der Regel nur einen kleinen Teil einer angebotenen Schnittstelle. Dieser Teil wird als Consumer Contract bezeichnet. Die Summe der Consumer Contracts ist dann das, was der Provider tatsächlich als Schnittstelle zur Verfügung stellen muss. Häufig ist dieser Teil kleiner als der tatsächlich angebotene Provider Contract. Insbesondere, wenn der Provider noch alte Versionen von APIs anbietet, wäre ein Mechanismus wünschenswert, mit dem er feststellen könnte, dass es keinen Client mehr gibt, der diese noch benutzt.

Die Idee hinter Consumer-driven Contracts ist es, genau das zu erreichen, indem die Clients kontinuierlich ihre Consumer Contracts an den Provider übermitteln. Der kann sie dann verwenden, um seine angebotenen Schnittstellen zu testen. Alle Schnittstellen, zu denen es keinen aktuellen Consumer Contract gibt, können entfernt werden.

Aber auch wenn es darum geht, eine neue Schnittstelle zu definieren oder eine Schnittstellenerweiterung vorzunehmen, erweisen sich Consumer-driven Contracts als sinnvoll.

Anforderungspraxis

Wenn es nämlich nicht gerade um ein öffentliches API geht, ist der Normalfall, dass Schnittstellen nur geändert werden, weil einer der Clients diese Änderung benötigt. Was liegt da näher, als dass auch der Client die Änderung der Schnittstelle spezifiziert. Es versteht sich von selbst, dass das natürlich in Rücksprache mit dem Team geschehen muss, dass die Schnittstellenänderung später implementieren muss.

Mit Consumer-driven Contracts ist eine solche Möglichkeit geschaffen. Der Consumer, der die neue Schnittstelle benötigt, erstellt einfach einen Consumer Contract und übermittelt ihn an den Provider. Bei richtigem Einsatz bietet diese Variante die Chance, die Qualität des Austauschs zwischen den beiden Teams erheblich zu verbessern. Jede Absprache wird in einem Consumer Contract formal und sehr detailliert (auf Request-Response-Basis) festgehalten. Das impliziert z. B. auch die Beschaffenheit der Testdaten. Missverständnisse fallen spätestens dann auf, wenn der Provider den Consumer Contract mit seiner Test-Pipeline testet.

Der Zustand des Providers

Wenn ein Request an einen Server gesendet wird, wird der je nach seinem Status oder Zustand anders antworten. Bei komplett stateless implementierten Servern befindet sich der Zustand normalerweise vollständig in der Datenbank. Es kann aber auch andere Einflussfaktoren auf den Zustand geben, wie zum Beispiel die Antworten anderer Umsysteme, die vom Server angefragt werden.

In der Behandlung des Zustands des Providers liegt die große Schwäche von Consumer-driven Contracts. In Pact z. B. werden Testdaten nur durch einen String spezifiziert, den Provider State. Welche Daten und welches Verhalten konkret hinter diesem String stehen, ist nicht Inhalt der Pact-Spezifikation und muss an anderer Stelle spezifiziert und dokumentiert werden. In Spring Cloud Contract gibt es nicht mal das und es muss von einem fixen Testdatenset, also einem eindeutigen Zustand ausgegangen werden.

Was Consumer-driven Contracts also nicht ersetzen und was auch nicht über sie getestet werden kann, ist das korrekte Verhalten der Applikation unter bestimmten Voraussetzungen. Das muss vom Service-Provider-Team explizit getestet, kommuniziert und dokumentiert werden. Wenn das nicht geschieht und wenn sich die Entwickler dieser Schwäche nicht bewusst sind, kann es passieren, dass Consumer-driven-Contract-Tests eine trügerische Sicherheit bieten: Nur weil alle Tests grün sind, heißt das nicht, dass sich der Provider in jeder Situation so verhält wie der Consumer es erwartet. Es heißt lediglich, dass die Consumer- und Provider-Schnittstellen syntaktisch zusammenpassen. Aber auch das ist ein großer Fortschritt entgegen dem aktuellen Zustand vieler Entwicklungssysteme. Häufig ist es hier noch Standard, dass zu Beginn einer End-to-End-Testphase erstmal ein langer Zeitraum benötigt wird, um die Test-Stage in einen stabilen Zustand zu bekommen, in dem alle Schnittstellen zusammenpassen. Dieses Ziel kann durch Consumer-driven Contract Testing automatisch erreicht werden, wenn die Deployment-Pipelines entsprechend aufgebaut sind.

Passende Versionen auf den Stages

Die Verwendung von Consumer-driven Contracts zum Auffinden nicht mehr verwendeter APIs oder die Verwendung als Austauschformat zur Spezifikation neuer Schnittstellen oder Schnittstellenänderungen sind nur ein sinnvoller Anwendungsfall.

Ihre richtige Stärke spielen Consumer-driven Contracts erst aus, wenn durch sie sichergestellt wird, dass auf jeder Stage nur Services deployt werden, deren Schnittstellen zusammenpassen. Das kann durch ein zentrales Speichern der Contracts (z. B. in einem Pact Broker) geschehen und damit verbunden mit dem Einbau von Consumer-driven Contract Testing in die Deployment Pipeline.

Dabei ist die Idee, dass ein Consumer vor dem Deployment alle seine Contracts an zentraler Stelle ablegt, dann seine Provider anstößt, die verifizieren, ob die hochgeladenen Contracts zur jeweils deployten Version des Providers kompatibel sind, und dann wiederum den Consumer über das Ergebnis informieren. Ist das Ergebnis jedes Providers positiv, kann der Consumer seinerseits deployen. Nach dem erfolgreichen Deployment markiert der Consumer seinen Contract, um zu signalisieren, dass der passende Client zum Contract auf der entsprechenden Stage deployt ist. Das ist hilfreich für den Fall, dass der Provider eine neue Version auf der Stage deployen möchte. Er braucht dann seine neue Version nur gegen alle Contracts der Clients (Consumer) zu testen, die auf der entsprechenden Stage bereits deployt sind. Obwohl diese Abhängigkeit zwischen den Tests besteht, können die einzelnen Build-Jobs durch den Einsatz von WebHooks entkoppelt werden.

Ein weiterer Vorteil von Consumer-driven Contract Testing folgt direkt aus dem Ablegen der Contracts an zentraler Stelle (also z. B. dem Pact Broker). Genau an dieser Stelle befinden sich nämlich alle Informationen darüber, welcher Service welchen aufruft, also über die Service-Abhängigkeiten. Der Pact Broker kann sie sogar direkt graphisch darstellen und ist somit ein Tool, um diese Abhängigkeiten im Griff zu behalten.

Fehlen eines einheitlichen Formats

Aktuell gibt es zwei Frameworks, die Consumer-driven Contract Testing unterstützen. Da ist einerseits das Pact Framework – genauer gesagt handelt es sich dabei sogar um eine Framework-Sammlung, die die Möglichkeit der Erstellung von Consumer-driven Contract Tests in verschiedensten Programmiersprachen ermöglicht. Das zweite Framework ist Spring Cloud Contract, das Toolunterstützung für die Erstellung von Consumer-driven-Contract-Tests im Spring-Umfeld liefert.

Leider handelt es sich bei den beiden aber nicht nur um zwei Anbieter für das Tooling von Consumer-driven Contracts, sondern auch um zwei Möglichkeiten zur Formulierung der Contracts selbst. Die beiden Frameworks verwenden leider standardmäßig unterschiedliche Formate für Consumer Contracts.

In einer polyglotten Microservices-Landschaft ist das ein Problem. Es kann passieren, dass ein Consumer den Contract im Pact-Format zur Verfügung stellt, der Provider den Contract aber im Spring-Cloud-Contract-Format verarbeiten will. Offensichtlich ist das ein Problem.

Die Konsequenz ist, dass das Contract-Format Teil der Makroarchitektur der Microservices werden muss, d. h., es muss ein Format für alle Services festgelegt werden.

Wenn man nur mit Spring Microservices unterwegs ist und auch keinen JavaScript-Client hat, der Contracts zur Verfügung stellen möchte, ist es kein Problem, das Format von Spring Cloud Contract zu verwenden. Allerdings bietet Spring Cloud Contract bisher nur die Möglichkeit, Tests in Java zu schreiben. Für Consumer-Tests kann zwar der generierte Mock-Server in beliebigen Sprachen verwendet werden, aber um ihn zu starten, benötigt man eine Build-Infrastruktur, die Java zur Verfügung hat. Das ist z. B. bei reinen JavaScript-Clients normalerweise nicht der Fall. Sobald also ein Client ins Spiel kommt, der nicht aus der Spring-Welt (oder eben nicht einmal aus der Java-Welt) kommt, ist das Spring-Cloud-Contract-Format problematisch.

Glücklicherweise bietet Spring Cloud Contract aber die Möglichkeit des Im- und Exports von Pact Contracts. Wenn ich also den umgekehrten Weg gehe und für meine Makroarchitektur (also für alle Services) definiere, dass das Pact-Format verwendet werden soll, dann können einzelne Teilnehmer dennoch Spring Cloud Contract in Kombination mit dem entsprechenden Im- oder Export verwenden. Aufpassen muss man dann nur mit den Teilen, in denen die beiden Formate nicht dieselben Informationen enthalten.

Alternative Ansätze

Gerade der Abschnitt über die passenden Versionen auf den Stages zeigt die Komplexität, die eine Verwendung von Consumer-driven Contracts mit sich bringt. Gleichzeitig sind sie nur sinnvoll, wenn die Abdeckung der APIs vollständig ist. Ansonsten wären sie z. B. nicht dazu geeignet, festzustellen, ob ein API noch verwendet wird. Es könnte auch sein, dass einfach kein Contract für die Verwendung geschrieben wurde.

Außerdem funktioniert der Ansatz natürlich nur für interne APIs. Bei public oder semi-public APIs sind die Consumer in der Regel gar nicht bekannt. Und selbst wenn, wird man sie nur schwer dazu bekommen, Contracts zur Verfügung zu stellen. Für solche Fälle empfiehlt sich eher der Fokus auf den Provider Contract. Der kann z. B. durch JSON Schema definiert werden. Dann kann mit Tools wie REST Assured getestet werden, ob sich die Implementierung tatsächlich an den Contract hält, ohne dass ein Consumer involviert ist.

Um dem Problem des Zustands und des Verhaltens der Provider zu begegnen, schlägt Nicole Rauch vor, anstelle von Consumer Contracts einen Mock-Server zu implementieren, der die sogenannte Functional Essence zur Verfügung stellt, also den funktionalen Kern des Providers ohne Nebenaspekte wie Persistenz, Security usw. und deshalb leicht zu implementieren ist. Er muss sich natürlich genauso verhalten, wie der echte Provider und kann dann zum Testen der Consumer verwendet werden.

Fazit

Consumer-driven Contract Testing bietet eine Möglichkeit, die Abhängigkeiten von Services zu testen und – über die Integration in die CI/CD-Pipeline – sicherzustellen, dass auf einer Stage nur passende Versionen deployt sind. Auf diese Weise kann garantiert werden, dass APIs nur abwärtskompatibel weiterentwickelt werden. Das gefahrlose Entfernen nicht mehr verwendeter APIs und die Visualisierung der Abhängigkeiten zwischen den Services bekommt man dabei quasi geschenkt.

Man erkauft sich diese Vorteile aber einerseits durch ein komplexes Entwicklungs- und Build-Set-up. Außerdem kann es passieren, dass erfolgreiche Contract-Tests eine trügerische Sicherheit bieten. Sie sagen nicht mehr aus, als dass die Schnittstellen syntaktisch passen. Einen Ersatz für fachliche Tests bieten sie nicht. Auch die Definition und Verwaltung der möglichen Testzustände eines Providers können mitunter sehr komplex werden und erfordern weiterhin eine Kommunikation zwischen den Teams. Ist man bereit, diese Komplexität auf sich zu nehmen, bieten Consumer-driven-Contract-Tests aber eine solide Basis für eine funktionierendes Zusammenspiel der Services in einer Microservices-Landschaft.

Geschrieben von
Arne Limburg
Arne Limburg
Arne Limburg ist Softwarearchitekt bei der open knowledge GmbH in Oldenburg. Er verfügt über langjährige Erfahrung als Entwickler, Architekt und Consultant im Java-Umfeld und ist auch seit der ersten Stunde im Android-Umfeld aktiv.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: