Was ist eine API und was sind eigentlich die Probleme?
Application Programming Interfaces (APIs) sind Programmierschnittstellen, welche von Anwendern oder Programmen genutzt werden können, um wohldefinierte Aufgaben zu erledigen. In diesem Artikel geht es um eine spezifische Form von APIs, welche von REST-Services (bzw. RESTful-Services) über das HTTP-Protokoll bereitgestellt werden. REST steht für Representational State Transfer. Diese Art von APIs sind zum Beispiel im Umfeld von Microservices weit verbreitet. Ziel dieses Ansatzes ist es, eine lose gekoppelte Applikation zu erhalten.
Im Laufe der Zeit entstehen so zahlreiche Schnittstellen, die kontinuierlich weiterentwickelt werden. Konsument und Produzent entwickeln sich unabhängig weiter, sodass bei der Betrachtung der Schnittstellen bestimmte Aspekte (zum Beispiel Versionierung) berücksichtigt werden müssen. Die Konsumenten sind nicht immer bekannt, sodass nicht nachvollzogen werden kann, wer welche Schnittstelle aufruft. Es sollte daher Postel´s Law (Robustheitsgrundsatz, [1]) eingehalten werden: be conservative in what you do, be liberal in what you accept from others. Mit anderen Worten: Die eigenen Schnittstellen sollten immer vollständig der Spezifikation entsprechen, allerdings sollten eingehende Anfragen nicht so streng überprüft werden.
REST-Richtlinien
REST gibt keine harten Regeln der Implementierung vor, sondern stellt lediglich Richtlinien auf. Im Folgenden erklären wir die in Abbildung 1 zusammengefassten Richtlinien.
Abb. 1: Richtlinien
Stateless
Das Konzept Stateless (zustandslos) ist besonders im Bereich der Webentwicklung und der Programmierschnittstellen relevant. Es beschreibt einen Zustand, bei dem ein konkretes System – in diesem Fall in der Regel der Server – keine Informationen über vorherige Interaktionen oder Transaktionen speichert. Jeder Request wird unabhängig von den bisherigen Requests behandelt. Dies bedeutet, dass der Server keine Zustände speichert und bei jedem Request alle notwendigen Informationen übergeben werden müssen. Da keine Zustandsinformationen zwischen den Requests gespeichert werden, können die Server sehr leicht horizontal skaliert werden. Jeder Request muss demnach von verschiedenen Servern verarbeitet werden können. Eine automatische Lastverteilung ist hierdurch ebenfalls möglich. Bei Ausfall eines Servers kann zudem ein anderer Server die neuen Requests verarbeiten, da sich kein Zustand gemerkt werden muss.
Uniform Interface
Eine uniforme Schnittstelle ist das Kernkonzept von REST und zeichnet sich dadurch aus, dass Ressourcen durch URIs (Uniform Resource Identifiers) adressiert werden. Hierdurch können Ressourcen angesprochen werden, unabhängig davon, wo sie sich physikalisch auf dem Server befinden. Es ist dabei unerheblich, welche Plattform dem Server zugrunde liegt – der verwendete Technologie-Stack ist für den Client transparent und kann auf dem Server flexibel gestaltet werden.
Die Schnittstellen müssen nicht explizit je Anwendungsfall spezifiziert werden, da durch die einheitliche Definition generische Schnittstellen möglich werden. Das Konzept fördert eine lose Kopplung zwischen Client und Server, da die Schnittstellen klar und eindeutig identifiziert werden.
Client-Server
Eine Client-Server-Architektur sollte forciert werden. Es handelt sich hierbei um ein bewährtes Konzept zur Interaktion zwischen einem Client und einem Server. Der Client ist der Konsument einer Schnittstelle und fordert Ressourcen eines Servers an. Der Server ist verantwortlich für die Geschäftslogik und die Datenspeicherung; er kümmert sich jedoch nicht um den Benutzerzustand.
Diese klare Trennung von Verantwortlichkeiten unterstützt eine effiziente Skalierung eines Systems. Client und Server können unabhängig voneinander entwickelt werden, solange die Schnittstellen – also die Verträge – eingehalten werden.
Layered System
Die Verwendung von Schichten ermöglicht eine Trennung der Funktionalitäten. Jede Schicht setzt ihre eigene Funktionalität um und bietet diese den anderen benachbarten Schichten an. Auch hier gilt das Prinzip der Bindung und Kopplung. Eine Schicht sollte eine starke Bindung haben, das heißt, zusammenhängende Funktionalität sollte in einer Schicht zusammengefasst werden. Andererseits sollte eine Schicht eine schwache Kopplung haben, das heißt, die Abhängigkeiten zu anderen Schichten sollten auf das notwendige Minimum reduziert werden.
Ein Beispiel für diese Schichten wäre die Implementierung der eigentlichen Programmierschnittstelle im Service A und die Überprüfung der Sicherheitsanforderungen in Service B. Für den Aufrufer ist lediglich Service A bekannt und es ist transparent für ihn, welche weiteren Services danach aufgerufen werden.
Cacheable
Der Server kann festlegen, ob eine Response zwischengespeichert werden kann oder nicht. Zudem kann angegeben werden, wie lange diese Speicherung erfolgen kann. Durch diese Funktion können Serveranfragen reduziert werden, da eine einmal angefragte Ressource für eine bestimmte Zeit gültig sein kann und somit nicht erneut angefragt werden muss. Die Zwischenspeicherung erfolgt auf dem Client, der beliebig oft auf diese Ressource zugreifen kann. Üblicherweise wird über das Senden eines HTTP-Headers (bspw. Cache-Control), in dem alle notwendigen Informationen beim Response übergeben werden, Cacheable aktiviert.
Das Zwischenspeichern ist sinnvoll für statische Ressourcen, die nicht häufig geändert werden. Bei dynamischen Anfragen kann das Konzept jedoch nicht zum Einsatz kommen. Ein Beispiel hierfür ist die Abfrage der aktuellen Zeit.
Code on Demand
Diese optionale Funktion legt fest, dass Server den Clients ausführbaren Code zur Verfügung stellen können, beispielsweise Skripte wie JavaScript. Ein Server kann demnach in einer Response nicht nur statische Informationen wie HTML-Seiten, sondern auch ausführbaren Code zurückgeben. Dieser Code kann vom Client ausgeführt werden, um zusätzliche Funktionalität zu nutzen.
Ähnlich wie beim Prinzip Cacheable sorgt dies dafür, dass die Requests eines Clients zum Server reduziert werden können. Im Fall von Code on Demand wird dies durch lokal ausführbare Funktionalität erreicht.
Beispielaufruf
Eine klassische Request-Response-Aktion wird durch Abbildung 2 verdeutlicht.
Abb. 2: Klassische Request-Response-Aktion
Der Client sendet einen HTTP-Request beispielsweise als GET-Request an den Server. Der Server bietet diverse Endpunkte an, die unterschiedliche Funktionalitäten bereitstellen. Als HTTP-Methoden können typischerweise GET, POST, PUT, DELETE
oder PATCH
verwendet werden.
Versionierung der Schnittstellen
Im Laufe der Zeit durchläuft ein Service üblicherweise zahlreiche Änderungen. Durch neue oder veränderte Anforderungen müssen die Schnittstellen angepasst werden. Die Versionierung von Schnittstellen ist somit ein entscheidender Aspekt bei der Umsetzung. Das erklärte Ziel ist es, die notwendigen Anpassungen durchzuführen, ohne die bestehenden Clients zu beeinträchtigen, die auf die Schnittstellen zugreifen. Für die Versionierung existieren verschiedene Varianten, deren Vor- und Nachteile Tabelle 1 vorstellt.
Beschreibung | Vor- und Nachteile | |
---|---|---|
URL | Die Versionierung wird über die Festlegung einer Versionsnummer direkt in der URL realisiert. Beispiel: http.../v1/neuerservice |
+ Klare und für den Anwender sofort sichtbare Abgrenzung der Version. + Diverse Versionen können parallel verwendet werden. + Die Aktualisierung gestaltet sich sehr einfach. - Client wird beim Entfernen der URL/Version zur Umstellung gezwungen. - Die Versionsbezeichnungen unterliegen einer Beschränkung, da die URL nicht beliebig lang sein darf. - Bei Versionsänderung müssen Caches aktualisiert werden. |
Query-Parameter | Die Versionierung wird in der URL als Query-Parameter übergeben. Beispiel: http.../neuerservice?version=1 |
+ Die URL des Service wird nicht geändert, dadurch werden keine weiteren Anpassungen an anderen Stellen erforderlich. + Die Versionsnummer ist direkt in der URL ersichtlich. + Kein zusätzlicher Custom-Header notwendig. - Die Verwaltung von Schnittstellen mit mehreren Endpunkten und Parametern kann unübersichtlich werden. - Ein Client kann versehentlich eine neue Version verwenden, falls der Parameter nicht gesetzt wurde. - Ein zusätzlicher Parameter ist notwendig. |
Header | Die Versionierung erfolgt anhand eines Konstantennamens für die Versionsangabe, welcher als Header übergeben werden kann. Beispiel: Header-Parameter "X-Api-Version: 1.0" |
+ Die URL des Service wird nicht geändert, dadurch werden keine weiteren Anpassungen an anderen Stellen erforderlich. + Die Versionsinformationen sind nicht öffentlich in der URL sichtbar. + Anfragen gegen verschiedene Versionen können durchgeführt werden, ohne die Aufrufstruktur zu verändern. - Die Versionsnummer ist nicht direkt beim Aufruf ersichtlich. Dies erhöht bei Tests die Komplexität. - Ein Client kann versehentlich eine neue Version verwenden, falls der Parameter nicht gesetzt wurde. - Header-Parameter werden möglicherweise nicht von allen Systemen unterstützt. |
Content Negotiation |
Die Versionierung erfolgt über den geforderten Inhaltstyp einer Ressource. Beispiel: Header-Parameter "Accept : application/json; version=1.0" |
+ Der Client kann die gewünschte Version flexibel bestimmen. + Die URL des Service wird nicht geändert, dadurch werden keine weiteren Anpassungen an anderen Stellen erforderlich. + Es können verschiedene Formate verwendet werden. - Deprecations sind in der OpenAPI nicht gut abzubilden. - Erhöhte Komplexität durch Ressourcen mit verschiedenen Versionen in einem Service. - Zusätzlicher Parameter notwendig. |
Fachliches Beispiel: Zeitabfrage
Anhand eines Beispiels sollen ausgewählte Aspekte skizziert werden. Für die Versionierung wird der Ansatz der URL-Versionierung verwendet. Die unterschiedlichen Versionen sind demnach direkt in der aufrufenden URL ersichtlich. Die Programmierschnittstelle ermöglicht es dem Aufrufer, die aktuelle Zeit mittels einer übergebenen Zeitzone zu ermitteln. Als Client wurde eine Web-Seite implementiert, welche die Zeit vom Service abfragt und diese darstellt. Das Beispiel ist zwar einfach, aber die bei der Versionierung verwendeten Prinzipien sind gut erkennbar und können durchaus auf komplexere Szenarien übertragen werden.
Ein Service entsteht ...
Das Entwicklungsteam hat den Service endlich vollständig implementiert und bereitgestellt. Die Programmierschnittstelle ist nun unter der URI https://myHost/v1/time/{timezone} erreichbar, zum Beispiel https://myHost/v1/time/UTC. Der Service antwortet darauf mit der entsprechenden Response, siehe Listing 1.
Request:
https://myHost/v1/time/UTC
Response:
{
"time": "2024-04-09T15:12:14.3580154"
}
... und wird benutzt
Das Client-Team hat schon lange auf diese Funktionalität gewartet und baut diese direkt in eine ihrer Seiten ein, siehe Abbildung 3.
Abb. 3: Das Client-Team baut die Funktionalität direkt ein
Das Antwort-JSON wird dabei in ein JavaScript-Objekt umgewandelt und die Daten entsprechend ausgelesen, siehe Listing 2.
:
let dataTime = data.time;
let datetime = new Date(dataTime);
document.getElementById('resultLabel').textContent =
datetime.toLocaleString());
drawClock(datetime);
Der Service entwickelt sich
Schnell wird klar, dass die Antwort des Service so nicht ausreichend ist. Falls der Client sich nicht gespeichert hat, welche Zeitzone er angefragt hat, kann das Ergebnis der Anfrage nicht sinnvoll ausgewertet werden. Daher soll die Zeitzone ebenfalls der Antwort hinzugefügt werden, siehe Listing 3.
Request:
https://myHost/v1/time/UTC
Response:
{
"time": "2024-04-09T15:21:41.3424094",
"timezone": "UTC"
}
Da hier keine semantische Änderung vorliegt und auch alle Elemente der vorherigen Rückgabe (das Attribut "time"
) weiterhin vorhanden sind, ist es nicht notwendig, dass eine neue Version des Endpunkts erstellt wird. Alle Clients, die Postel's Law erfüllen, werden keine Probleme mit dieser Änderung haben. Und so funktioniert die oben beschriebene Seite weiterhin.
Noch 'ne Änderung
Da der Attributname "time"
recht unpräzise ist, soll dieser umbenannt werden. Entsprechend ändert sich die Antwort des Service. Damit hat sich die Struktur der Anwendung signifikant geändert. Bestehende Clients würden nicht mehr funktionieren. Somit handelt es sich um eine neue Version des Service, welche unter einer anderen URI angeboten wird: https://myHost/v2/time/UTC, siehe Listing 4.
Request:
https://myHost/v2/time/UTC
Response:
{
"currentTime": "2024-04-09T15:40:46.9281622",
"timezone": "UTC"
}
Gleichzeitig wird die Version 1 des Service deprecated, das heißt zum Entfernen markiert. Dies kann zum Beispiel in der entsprechenden Schnittstellendokumentation vermerkt oder über entsprechende Informationsmechanismen (RSS-Feeds, Newsletter usw.) an die Schnittstellennutzer kommuniziert werden. Das ideale Verfahren hängt hierbei von der (Geschäfts-)Beziehung zwischen Service-Consumer und Service-Provider ab. Zusätzlich kann auch in der Antwort des Service eine entsprechende Markierung bereitgestellt werden, zum Beispiel über einen HTTP-Response-Header: X-Deprecated=true
(oder eine genauere Information zur Verfügbarkeit oder den notwendigen Aktionen), siehe Listing 5.
Request:
https://myHost/v1/time/UTC
Response:
X-Deprecated: Available until Q3/2024
{
"time": "2024-04-09T15:40:46.9281622",
"timezone": "UTC"
}
Dass ein abgekündigter Service diese Antwort liefert, sollte im Vorfeld bereits publik gemacht werden (zum Beispiel in der Schnittstellendokumentation). Idealerweise werden noch weitere Informationen zurückgegeben, wie die stattdessen zu nutzende URL, der geplante Zeitpunkt der Abschaltung (s. Response-Beispiel) oder der Link auf die aktuelle Dokumentation.
Schnittstelle mit zusätzlichem Parameter
Es soll nun ein optionaler Parameter offset
hinzugefügt werden, der die aktuelle Zeit um eine bestimmte Anzahl von Stunden verschiebt (zum Beispiel, um das Ende einer Besprechung zu berechnen, die gerade beginnt und eine Stunde dauern soll). Da der Parameter nicht zwingend erforderlich ist, können Clients ohne weitere Änderung mit der Schnittstelle interagieren und es ist nicht zwangsläufig eine neue Version des Service zu etablieren, siehe Listing 6.
Request:
https://myHost/v2/time/UTC?offset=1
Response:
{
"currentTime": "2024-04-09T15:40:46.9281622",
"timezone": "UTC"
}
Parameter wird verpflichtend
Soll nun der offset
-Parameter verpflichtend werden, so ergibt sich zwangsläufig, dass eine neue Service-Version bereitgestellt werden muss: https://myHost/v3/time/UTC?offset=1. Selbstverständlich wird Version 2 in diesem Fall wie zuvor Version 1 abgekündigt (deprecated), siehe Listing 7.
Request:
https://myHost/v3/time/UTC?offset=1
Response:
{
"currentTime": "2024-04-09T15:40:46.9281622",
"timezone": "UTC"
}
Semantik des Parameters verändert sich
In der Praxis hat sich gezeigt, dass Stunden als Einheit für den offset
nicht praktikabel und zu grobgranular sind. Eine Änderung auf Minuten soll umgesetzt werden. Dies führt natürlich zu einer neuen Version des Service, da sich das Verhalten bei gleicher Eingabe funktional ändert: https://myHost/v4/time/UTC?offset=30, siehe Listing 8.
Request:
https://myHost/v4/time/UTC?offset=30
Response:
{
"currentTime": "2024-04-09T15:10:46.9281622",
"timezone": "UTC"
}
Ballast muss man loswerden (können)
Wie wir bereits erläutert haben, gibt es unterschiedliche Mechanismen, einzelne Service-Versionen als abgekündigt zu markieren. Ist eine ausreichende Zeit verstrichen, sodass Service-Consumer auf die Abkündigung reagieren konnten, kann die Service-Version entfernt werden. Wie bereits erwähnt, müssen möglicherweise vertragliche Pflichten berücksichtigt werden. Die gleichen Prinzipien greifen auch, wenn die gesamte Funktionalität (ersatzlos) entfernt werden soll. Auch hier wird der Service-Consumer mittels der entsprechenden Mittel informiert und kann darauf reagieren (Ausbau der Funktionalität, Suche nach Alternativen).
Bevor der Service (bzw. die Version) wirklich entfernt wird, lohnt sich ein Blick in die hoffentlich vorhandenen Monitoring-Daten. Idealerweise ist damit nachvollziehbar, wann die URI einer bestimmten Serviceversion zuletzt aufgerufen wurde. Gegebenenfalls müssen dabei noch typische Nutzungsprofile berücksichtigt werden: Wenn zum Beispiel ein Service normalerweise immer am Anfang eines Monats aufgerufen wird, ist eine Betrachtung der letzten zwei Wochen am Ende des Monats sicher nicht hinreichend. Durch die Analyse der Monitoring-Daten können möglicherweise „böse Überraschungen“ vermieden werden. Nach eingehender Prüfung kann dann der entsprechende Code schließlich entfernt werden.
Fazit
Als Provider einer Schnittstelle sollte man sich frühzeitig Gedanken über den Lebenszyklus seiner APIs machen. Bereits bei der Erstellung muss klar sein, dass auch eine Programmierschnittstelle einen Zyklus von der Konzeption bis zur Abschaltung durchläuft. Insbesondere sollte eine API auf Veränderungen vorbereitet sein und der Provider die passenden Strategien zur Hand haben, damit die aufrufenden Clients eine konsistente Schnittstelle vorfinden. Die Nutzung von Richtlinien unterstützt die Erstellung konsistenter Schnittstellen. Eine saubere Abkündigungsstrategie sorgt dafür, dass die Clients sich frühzeitig um die Anpassung ihrer Aufrufe kümmern können (sowohl bei semantischen als auch bei syntaktischen Änderungen).
Als aufrufender Client einer Schnittstelle sollte Postel's Law berücksichtigt werden. Beim Senden von Requests sollten diese konservativ sein, das heißt, es sollten alle Richtlinien und Standards eingehalten werden. Das Verarbeiten der Antworten sollte liberal erfolgen, das heißt, es sollte versucht werden, die Antworten trotz eventueller Abweichungen dennoch zu interpretieren oder zu verarbeiten, soweit es möglich ist. Ein Client sollte die Deprecation-Strategie des Service-Providers kennen und diese auch berücksichtigen, um nicht von semantischen oder syntaktischen Änderungen überrascht zu werden. Letzteres ist umso wichtiger, wenn keine vertragliche Beziehung besteht, also bei öffentlichen APIs, da der API-Consumer in diesem Fall in der Regel keine Handhabe hat, Einfluss auf den Provider auszuüben.
Werden die in diesem Artikel vorgestellten Prinzipien konsequent genutzt, spricht einer Bereitstellung und Nutzung sich weiterentwickelnder APIs aus unserer Sicht nichts entgegen.
Weitere Informationen
[1] https://de.wikipedia.org/wiki/Robustheitsgrundsatz
[2] R. T. Fielding, Kapitel 5, Dissertation, UCI Donald Bren School of Information & Computer Sciences, 2000, https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm
[3] L. Gupta, HTTP Methods, 4.11.2023, https://restfulapi.net/http-methods/
[4] L. Gupta, Rest Architectural Constraint, 6.11.2023, https://restfulapi.net/rest-architectural-constraints/