5 Patterns zur Schnittstellenversionierung

5 Patterns zur Schnittstellenversionierung

Heutige Systeme sind meist modular bzw. verteilt aufgebaut und kommunizieren über Schnittstellen miteinander. Diese Kommunikation funktioniert natürlich nur, wenn sich beide Systeme darüber einig sind wie Informationen ausgetauscht werden und vor Allem auch was diese Information bedeutet.

Eine gute Dokumentation ist unabhängig davon ob nur ein oder mehrere Teams an bzw. mit der Schnittstelle arbeiten, unerlässlich. Für synchrone REST-basierte Aufrufe hat sich bei den Microservices z. B. OpenAPI verbreitet, bei asynchronen Schnittstellen wird AsyncAPI immer beliebter.

Ist ein gemeinsames Verständnis für die Schnittstelle geschaffen und eine Kommunikation eingerichtet, kann ja eigentlich nichts mehr schief gehen, oder?

Verhandlung der Schnittstelle

Verhandlung der Schnittstelle

Versionierung der Schnittstelle

Kein Geheimnis dürfte sein, dass sich in den meisten Projekten vorhandene Anforderungen oder sich die zugrundeliegende Daten mit der Zeit ändern. Dementsprechend wird es vorkommen, dass Schnittstellen angepasst werden müssen.

Ohne eine Versionierung der Schnittstelle wird man hier unter Umständen schnell in ernsthafte Probleme laufen.

  • Fehlende Rückwärtskompatibilität - Alte Anwendungen, die plötzlich das neue Format nicht unterstützen, funktionieren nicht mehr korrekt.

  • Unklare Kommunikation - Die Kommunikation zwischen den ‘Systemen’, wenn mehrere Teams beteiligt sind, kann problematisch werden.

  • Mangelnde Kontrolle über Änderungen - Wann wurde was geändert. Hat man potentiell mehr als einen Client der Schnittstelle, kann die Verwirrung schnell groß werden.

Klar sollte sein: Schnittstellen müssen versioniert werden. Es spielt hierbei keine Rolle ob es sich um eine synchrone oder eine asynchrone Schnittstelle handelt.

Klar darf der Verweis zu semver.org hier nicht fehlen. Im weiteren möchte ich allerdings nicht auf ein Versionsnummernschema eingehen.

Versionierung? Kompatibel oder nicht?

Bei jeder Änderung, die man an einer Schnittstelle vornimmt, muss man sich Gedanken darüber machen, ob die Änderung ein breaking change darstellt, also die Kommunikation ‘bricht’ und mindestens ein System nicht mehr korrekt funktioniert, oder ob die Auswirkungen der Änderung nicht so groß sind und alle Systeme weiterhin korrekt arbeiten.

Die Erkennung von “breaking changes” ist manchmal nicht so einfach, wie es auf den ersten Moment erscheint. Es ist Vorsicht geboten, denn man sollte nicht nur technische Aspekte betrachten! Änderungen können auch zu semantisch sein. Also Änderungen, die trotz einer fehlerfreien Übertragung dazu führen, dass mindestens ein System nicht mehr korrekt arbeitet. Ein Beispiel sind Werte, die in einer neuen Version eines Systems “plötzlich” vorausgesetzt werden, die in einer früheren Version optional waren.

Bei der Übertragung der Daten zwischen den Systemen werden meist plattformneutrale Datenformate wie JSON, Protobuf oder auch XML eingesetzt. Also Formate, die von den verschiedensten Programmiersprachen unterstützt werden und mit denen man auch “kleinere Änderungen” im Format vornehmen kann.. vergisst man einmal Schemata…

Ein immer wieder gern genommener Fehler: Ein Systems geht davon aus, dass das Datenformate, das verwendet wird, in einem anderen System auf eine bestimmte Weise interpretiert und verarbeitet wird.

Bei JSON können z. B. die Standardeinstellungen von Parsern gerne zu Problemen führen. Werden neue Felder in die JSON-Struktur aufgenommen, kann es passieren, dass ein Parser diese nicht, wie ursprünglich gedacht, ignoriert, sondern das zusätzliche Feld als Fehler meldet. Wer mit Jackson, Java und JSON gearbeitet hat, kennt in diesem Zusammenhang sicher auch die Option FAIL_ON_UNKNOWN_PROPERTIES.

new ObjectMapper()
  .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)

Für Go im Vergleich sind ersteinmal alle Felder eines Structs bei der Serialisierung/ Deserialisierung optional und müssen explizit geprüft werden. Am Geschicktesten geht das sicher mit Pointern und nil-Prüfungen.

type Article struct {
  Id      *string `json:"id,omitempty"`
  ...
}

Eine falsche Annahme wie: “Wir schicken ja nur ein Feld mehr, die werden das ja ignorieren”, kann recht schnell “auf den Brettern” enden und zu Fehlern führen.

Also Vorsicht bei einer Aussage wie: “Wir haben nix gemacht. Ist kein breaking change, nur ein neues Feld aufgenommen, aber das brauchen die ja nicht”.

Chaos nach Änderung

Chaos nach Änderung

Oftmals sind vermeidlich kleine Änderungen, nicht wie gedacht, doch ein “breaking change” und müssen auch dementsprechend behandelt werden!

VORSICHT bei Änderungen im Datenformat. Im Zweifelsfall eine neue Version der Schnittstelle veröffentlichen!

Ist das beim Messaging auch so?

In heutigen Systemlandschaften wird meist asynchron kommuniziert und die einzelnen Systeme tauschen über Messaging-Lösungen Nachrichten aus. Die einzelnen Teilnehmer nutzen sogenannte Channels um ihre Nachrichten zu versenden bzw. zu empfangen.

In der Theorie handelt es sich bei den Channels um “virtual pipes”, die nicht zwangsläufig persistiert werden müssen. In der Praxis haben sich allerdings Messaging-Systeme etabliert, die Nachrichten auch persistent ablegen. Dort heißen dann die Channels meist Topic oder Queue und können auf verschiedenste Weise, z. B. mit Sicherheitseinstellungen, konfiguriert werden.

Wie die Nachrichten in einem Channel aufgebaut sind und welches Datenformat verwendet werden soll, müssen auch hier die Kommunikationsteilnehmer miteinander aushandeln. Analog zu einer synchronen Schnittstelle.

Ein Channel entspricht im Groben bei der synchronen Kommunikation einem Endpoint.

Es gelten also ersteinmal die gleichen Regeln in Bezug zu den “breaking changes” der übermittelten Nachrichten.

Wie kann ich eine Schnittstelle versionieren? 5 Patterns!

Unabhängig davon ob man nun eine synchrone oder eine asynchrone Schnittstelle nutzt, muss man (ich möchte hier explizit nicht sollte schreiben) eine Schnittstelle versionieren bzw. mit Informationen ausstatten in welchem Format und mit welcher Semantik die Datenübertragung stattfindet.

“Versionieren” hat, wie oben bereits beschrieben, nicht nur mit dem reinen technischen Format zu tun, sondern auch mit der Semantik die der Schnittstelle zu Grunde liegt.

Im Folgenden wird nur noch von Nachricht bzw. Nachrichten gesprochen. Das sollen zum Einen asynchrone Nachrichten sein, aber auch die Nutzlast von synchronen Endpunkt-Aufrufen bezeichnen.

Rein technisch gesehen hat man 5 Patterns wie man eine Schnittstelle mit Versionsinformationen ausstatten kann bzw. wie man sicherstellen kann, das Änderungen am Format kein “breaking change” wird:

5 Patterns der Schnittstellenversionierung

5 Patterns der Schnittstellenversionierung

  • Pattern 1 “Endpoint for version” Bei der ersten Option nutzt man pro Version der Schnittstelle einen separaten Endpunkt. Das ist bei Rest-Calls z. B. eine URL pro Version oder im Falle einer asynchronen Kommunikation ein Channel pro Version, sprich z. B. ein Topic je Version.

Beispiel:

Rest:
.../v1/article
.../v2/article

Async:
Version 1 -> Topic 1
Version 2 -> Topic 2
  • Pattern 2 “Referencing message” Bei der zweiten Option wird eine Nachricht übermittelt, in der “nur” eine Referenz auf die verwendete Version enthalten ist.

Beispiel JSON Event Format for CloudEvents - Der Payload der Nachricht besteht in diesem Fall aus einem, durch die Cloud-Events Spezifikation beschriebenen, Container-Format. In den einzelnen Feldern sind Metainformationen zur Nachricht enthalten. So auch unter dem Feld datacontenttype die Referenz zum Datenformat, sowie die eigentlichen Nutzdaten unter data_base64.

{
    "specversion" : "1.0",
    "type" : "com.example.someevent",
    "source" : "/mycontext",
    "id" : "A234-1234-1234",
    "time" : "2018-04-05T17:31:00Z",
    "comexampleextension1" : "value",
    "comexampleothervalue" : 5,
    "datacontenttype" : "application/vnd.apache.thrift.binary",
    "data_base64" : "... base64 encoded string ..."
}
  • Pattern 3 “Selfcontained message” Bei dieser Option wird das komplette Schema, also die Formatbeschreibung, als Teil der eigentlichen Nachricht übertragen.

Beispiel AVRO Object Container Files. Hier wird in jeder Nachricht das Schema sowie eine Liste von entsprechenden Nachrichten verschickt.

{
  "namespace": "com.acme",
  "protocol": "HelloWorld",

  "types": [
    {"name": "Greeting", "type": "record", "fields": [
      {"name": "message", "type": "string"}]},
    {"name": "Curse", "type": "error", "fields": [
      {"name": "message", "type": "string"}]}
  ],

  "messages": {
    "hello": {
      "request": [{"name": "greeting", "type": "Greeting" }],
      "response": "Greeting",
      "errors": ["Curse"]
    }
  }
}
  • Pattern 4 “Message with referencing metadata” Diese Option beschreibt die “Grundidee von HTTP”. Der Payload enthält nur die eigentliche Nachricht, in den Metainformationen (z. B. Header-Werte bei HTTP) wird die Versions- bzw. Formatangabe übertragen. So sollten Rest-basierte Services eigentlich funktionieren…

Beispiel HTTP-Content-Type - Hier werden neben den Nutzdaten in protokollabhängiger Weise Metainformationen als HTTP-Header Werte übertragen. In diesem Fall wird “nur” eine Referenz zur eigentlichen Formatbeschreibung übertragen.

PUT http://www.source-fellows.com/api/audi
Content-Type: application/vnd.mydatatypeV1+json

{
  "Kennzeichen":          "RT-X-123"
}

So ist das Internet gedacht und sollte so funktionieren :-)

  • Pattern 5 “Message with selfdescribing metadata” Analog zu Option 4 nur dass in diesem Fall anstelle der Referenz auf die Formatbeschreibung, diese direkt mit in den Metadaten enthalten ist.

Meine Schnittstelle versionieren

Für welche Option man sich am Ende entscheidet ist vom Kontext abhängig. Für eine maximale Kompatibilität bieten sich, meiner Meinung nach, Option 2 und Option 4 an. Das ist auch der Weg, den, wie oben gezeigt, die Cloud-Events eingeschlagen haben.

In der Praxis wird oftmals auf das erste Pattern gesetzt, also ein Endpunkt pro Version, was in manchen Umfeldern sicher recht einfach eingesetzt werden kann. Kommen allerdings mehrere Producer ins Spiel, die wiederrum unterschiedliche Datenversionen verwenden, kann es auf Consumer-Seite ebenfalls recht komplex werden. Hier werden dann mehrere Endpunkte benötigt. Plötzlich steht man einer n:m Situation gegenüber. Jeder Endpunkt muss einzeln verwaltet werden, etc, etc…

Klar ist auf der anderen Seite auch, dass die auswertenden Systeme für die beiden anderen Varianten (Pattern 2 und 4) darauf vorbereitet sein müssen, unterschiedliche Datenformate zu verarbeiten. Auch hier entsteht Aufwand.

Wie immer: It depends… ;-)

25.01.2024

 

Der Author auf LinkedIn: Kristian Köhler und Mastodon: @kkoehler@mastodontech.de

Kennen Sie schon das Buch zum Thema?

Der praktische Soforteinstieg für Developer und Softwarearchitekten, die direkt mit Go produktiv werden wollen.

  • Von den Sprachgrundlagen bis zur Qualitätssicherung
  • Architekturstil verstehen und direkt anwenden
  • Idiomatic Go, gRPC, Go Cloud Development Kit
  • Cloud-native Anwendungen erstellen
Microservices mit Go Buch

zur Buchseite beim Rheinwerk Verlag Rheinwerk Computing, ISBN 978-3-8362-7559-0 (als PDF, EPUB, MOBI und Papier)

Kontakt

Source Fellows GmbH

Source Fellows GmbH Logo

Lerchenstraße 31

72762 Reutlingen

Telefon: (0049) 07121 6969 802

E-Mail: info@source-fellows.com