Datum
November 16, 2020
Kategorie
API
Lesedauer
14 Min.

APIs ohne den ganzen REST

Photo by Stefano Alemani on Unsplash

Früher …

Wenn es früher galt, Schnittstellen zwischen entfernten IT-Systemen zu etablieren und zu benutzen, hießen unsere Helden RPC, CORBA oder SOAP. Später führten die Konzepte des WWW bzw. die Einfachheit des HTTP-Protokolls und die damit verbundene gute Integrationsmöglichkeit in Clients zu einem Hype um RESTful APIs. Diese bilden heute den De-facto-Standard für webbasierte Schnittstellen. Doch das Blatt wendet sich.

In den vergangenen Jahren hat sich die Art und Weise, wie Daten über Web-Schnittstellen benötigt oder besser bereitgestellt werden, stark verändert. Schlanke Clients und komplexere Backend-Systeme, die Daten für Clients aufbereiten, weichen in immer stärkerem Maß schlanken Backends und eher komplexeren Clients. Schlüssel zum Erfolg dieses Ansatzes ist, wie Clients Daten anfordern oder manipulieren können. Hier spielt eine Rolle, ob und wie explizite Schnittstellen definiert werden müssen.

tl;dr

Wir setzen uns mit aktuellen Alternativen zu REST und deren konzeptionellen Ansätzen auseinander. Unsere Wahl ist auf folgende Kandidaten gefallen:

  • Falcor – Minimierung von Latenz durch Bereitstellung aller Daten in einem Modell
  • GraphQL – Schnittstellendefinition durch Anfragesprache mit Schema-Validierung
  • gRPC – einfaches, modernes RPC-Framework
  • Pure JSON – Austausch Daten mit JSON über Websockets

Unser Fokus liegt darauf, die REST-Alternativen mit ihren Eigenschaften und Anwendungsfällen zu beschreiben, nicht sie miteinander zu vergleichen.

Falcor – Eines für Alles

Falcor in einem Wort zu beschreiben ist nicht einfach: Protokoll, Datenplattform und Middleware für Web-Anwendungen treffen jedenfalls zu. Asynchrone Model-View-Control-(MVC)-Pattern-Implementierung aber auch. Gerade mit Hilfe dieses asynchronen Modells – dem M aus MVC – wird ein Grundproblem moderner Anwendungen adressiert: hohe Latenz bei der Anfrage nach Daten. Der Zugriff auf die Daten erfolgt mit Hilfe von JavaScript-Operationen wie get(), set(),call() etc.

Alles in einem Modell

Auf einem node.js-fähigen Server werden die relevanten Daten aus einer oder mehreren Datenquellen als ein virtueller in-memory-JSON-Graph – der model.json – aufgebaut und zur Verfügung gestellt. So können alle für einen Client relevanten Daten in einer einzigen Anfrage gegen die model.json statt mit vielen einzelnen Anfragen in verschiedenen Datenquellen bereitgestellt werden. Ändern sich die dem Graphen zugrunde liegenden Daten in den Datenquellen, muss der JSON-Graph serverseitig aktualisiert werden. Der Client wird darüber über eine Callback-Funktion informiert. Seinerseits kann der Client einen Teil seines Models als purged (zu reinigen) markieren und Daten vom Modell des Servers so neu anfordern.

Fragt ein Client Daten wiederholt an, werden diese nicht vom Server, sondern aus dem lokalen Cache des Modells geholt. Die Größe des Modells ist dabei bestenfalls physisch eingeschränkt. Die Komplexität von verteilten Anwendungen wird hier also vom Client in die Datenversorgung des Modells verlagert.

Wo geht’s denn hier zur API?

Die Antwort liegt in der Struktur der Daten. Kennst du das Datenmodell, kennst du auch die API.

var name = await model.getValue("apis[0].name");

Clientseitig nutzt Falcor JSON-Pfade statt URLs, wie wir sie aus REST kennen. Ein Bedarf an dedizierten REST-like-Schnittstellen und deren Implementierung besteht in dieser Form nicht.

Beim Beispiel oben gehen wir von der Existenz eines JSON-Arrays apis im Falcor-Modell aus, das über den Wert name verfügt. Die Herausforderung bei Falcor besteht also darin zu erkennen, wie die zur Verfügung stehenden Daten strukturiert sind und hier bietet Falcor leider keine Unterstützung – hier hilft nur Kommunikation mit dem Anbieter der Daten. Noch schwieriger kann es sein, die verwendeten Datentypen des Modells zu antizipieren, um damit sinnvoll arbeiten zu können.

The good, the bad …

✅ Ein Client fragt mit JavaScript-Mitteln den für ihn relevanten Ausschnitt von Werten auf dem JSON-Graph ab, als ob dieser lokal zur Verfügung steht. Der Zugriff auf ein remoteversorgtes Falcor-Datenmodell erfolgt sehr ähnlich. Der Client arbeitet dabei nicht direkt auf der model.json des Servers sondern auf seiner Modell-Repräsentation. Ein Unterschied zum lokalen Zugriff besteht: Das Falcor-Modell arbeitet asynchron.

❌ Grenzen hat Falcor in jedem Fall bei der Parametrisierung von Anfragen jenseits von Indizes und Index-Bereichen.

❌ Eine Suche auf Daten im Falcor-Modell ist nicht integriert.

✅ Falcor unterstützt Batching. Damit lassen sich mehrere potenziell kleinteilige Anfragen in eine Anfrage zusammenfassen und gemeinsam an den Server übermitteln.

✅ Falcor arbeitet mit Referenzen innerhalb seines JSON-Modells. Statt vielfach sich wiederholende Daten an den Client zurückzuliefern, werden diese Daten einmal inhaltlich und bei ihrer Verwendung an anderen Stellen im JSON mit ihrer Referenz zurückgegeben.

✅ Falcor unterstützt keine Möglichkeit, sich bei einer Anfrage das gesamte Modell zurückgeben zu lassen. Ein Modell, das gestern hundert Knoten hatte, kann heute schließlich hundert Millionen Knoten enthalten. Es ergibt also durchaus Sinn, bei einer Anfrage nur das zurückzuliefern, was tatsächlich angefragt wurde.

❌ Falcor bringt viele eingebaute Features mit. Security gehört nicht dazu. Hier ist man auf die Möglichkeiten im JavaScript-Universum angewiesen. Mit Routern lassen sich einzelne Pfade absichern.

✅❌ Sind die relevanten Daten einmal im Falcor-Modell gelandet, können diese potenziell ohne zusätzliche serverseitige Implementierung benutzt werden. Um die Behandlung von Updates müssen wir uns selbst kümmern.

✅ Sollen Pfade im Falcor-Modell speziell behandelt werden, bietet sich die Verwendung von Router-Klassen an, mit deren Hilfe mit JavaScript-Mitteln die zurückgelieferten Daten beeinflusst/transformiert werden können.

Für wen lohnt sich Falcor?

Besteht die Anforderung viele verteilte, lesende Operationen auf Daten durchzuführen, ist es mit Falcor auf vergleichsweise einfachem Weg machbar, diese Operationen sinnvoll zusammenzufassen und so die Latenz bei komplexen Anfragen zu verringern. Schreibende Zugriffe auf ein Falcor-Modell sind ebenfalls vorgesehen.

Falcor scheint jenseits der JavaScript-Implementierung keine aktive Community zu besitzen, was leider durch die gefühlt geringe Verbreitung von Falcor widergespiegelt wird. Neben der JavaScript-Variante gibt es eine Java-Implementierung, die zumindest auf Maven Central nicht regelmäßig gewartet wird. Falcor für .NET ist seit fünf Jahren verwaist.

Falcor ist leicht zu verstehen/zu erlernen und bringt die meisten Features für seine Benutzung bereits mit. Die Falcor API ist in jedem Fall gut dokumentiert. Eigene Anwendungsfälle lassen sich auf Basis vieler Demos zu Falcor zumindest prototypisch leicht erstellen.

GraphQL – bekommen, was man erwartet

Einen gänzlich anderen Ansatz Schnittstellen bereitzustellen liefert GraphQL. GraphQL ist auf der einen Seite API-Sprache, auf der anderen Seite Laufzeitumgebung und Typ-System für ebendiese API-Sprache sowohl für das Lesen als auch für das Verändern von Daten.

In welcher Form die über GraphQL-Server bereitgestellten Daten im Original vorliegen – Datenbank oder JSON-Graph – ist lediglich für den Server relevant. Für den Client erscheint der GraphQL-Server als eine Datenquelle, die über eine einzige Schnittstelle – hier /graphql – zur Verfügung steht.

Und nein: GraphQL hat vordergründig nichts mit Graphen-Datenbanken zu tun, auch wenn es für Neo4J – den Platzhirsch unter den Graphen-Datenbanken – eine GraphQL-Integration gibt. Markantes Merkmal von GraphQL ist die Struktur von Anfrage und der korrespondierenden Antwort – beide sind gleich. Auf die in GraphQL-Notation geschriebene Anfrage

query ApiNames {
  apis {
     name
  }
}

bekomme ich als Antwort die Namen aller Einträge des Arrays apis als JSON.

{
  "data": {
     "apis": [
        {"name": "GraphQL"},
        {"name": "Falcor"}
     ]
  }
}

In der Struktur der Anfrage ist die erwartete Struktur der Antwort formuliert. Interessiere ich mich nur für einzelne Elemente von apis, kann ich die korrespondierende id – sofern in dieser Form vorhanden – bei der Anfrage mitliefern

query ApiName ($id: Id){
  apis (id: $id) {
     name
  }
}

und erhalte bei id=1 zum Beispiel folgende Antwort:

{
  "data": {
     "apis": {
        "name": "GraphQL"
     }
  }
}

RESTful APIs sind in den allermeisten Fällen an die Verwendung von HTTP geknüpft. Bei GraphQL besteht die Möglichkeit zwischen HTTP und Websockets zu wählen.

Und wo ist hier die API?

Ähnlich wie bei Falcor bestimmt der Client welche Daten in welchem Format geliefert werden sollen. Durch parametrisierbare Anfragen kann exakt der gewünschte Umfang der Daten bereitgestellt werden.

Wer wünscht sich das nicht: Eine Auskunft der Schnittstelle, welche Abfragen/Typen/Parameter überhaupt möglich sind. Das Typ-System von GraphQL bringt dies bereits über das __schema-Feld auf der Wurzel jeder Schnittstelle mit. Die Anfrage

{
  __schema {
     types {
        name
     }
  }
}

liefert zum Beispiel:

{
  "data": {
     "__schema": {
        "types": [
           {"name": "String"},
           {"name": "ID"},
           ...
        ]
     }
  }
}

Man kann sich also bereits vor Benutzung einer GraphQL-Schnittstelle ein Bild darüber machen, welche Datentypen verwendet werden. Dieses Feature von GraphQL wird Introspection (Selbstbeobachtung) genannt.

The good, the bad …

✅ Struktur und Typen von Daten werden per Anfrage – per Schnittstelle – zweifelsfrei festgelegt. Der GraphQL-Server weiß exakt, in welcher Form angefragte Daten erwartet werden.

✅ Das Datenmodell auf einem GraphQL-Server kann aus mehreren Datenquellen aufgebaut sein. Die Daten dieser Datenquellen erhalten auf dem Server eine potenziell hierarchische Struktur, die den Zugriff erleichtert. Für den Client erscheint der GraphQL-Server als eine Datenquelle.

❌✅ Die serverseitige Implementierung für eine Anfrage muss anders als z.B. bei Falcor selbst geschaffen werden. Ob das gut oder schlecht ist, muss jeder für sich selbst herausfinden.

❌ Da alle Anfragen an eine GraphQL-Schnittstelle potenziell auf derselben URL landen, sind typische Security-Maßnahmen wie URL-Filter auf der Web Application Firewall bestenfalls wirkungslos. Das per Default öffentliche GraphQL-Schema muss explizit gegen unberechtigte Zugriffe geschützt werden.

✅ Die Nutzung ein- und derselben URL hat aber auch Vorteile: Reporting, Monitoring und Tracing wird leichter.

✅❌ Die Benutzung des GraphQL-Schemas für die Validierung von Request und Response kostet Laufzeit. Viel Laufzeit. Die Validierungsregeln können allerdings überschrieben und so implizit minimiert werden. So können eigene Regeln, die die Einhaltung des Schnittstellenvertrags durch den Client prüfen, angewendet werden.

✅ Ähnlich wie bei Falcor lassen sich mehrere Anfragen an einen GraphQL-Server zusammenfassen und gemeinsam bearbeiten. Die Latenz von Anfragen lässt sich so ähnlich wie mit Falcor minimieren.

✅ Suchfunktionen können mit GraphQL aufgebaut werden – anders als in Falcor.

✅ Parametrisierung von Anfragen sind mit GraphQL ein leichtes Spiel (Beispiel siehe oben).

Für wen lohnt sich GraphQL?

Das GraphQL-Typ-System, seine Anfragesprache und die zugehörige Laufzeitumgebung erlauben flexible Anfragen von Clients an einen GraphQL-Server ohne eine explizite Änderung der betreffenden Schnittstelle – anders als dies häufig bei RESTful APIs der Fall ist. Dabei spielt es keine Rolle, ob es sich um lesende oder schreibende Client-Zugriffe handelt. Für die Verwendung von GraphQL durch WebClients gibt es etablierte Frameworks wie z.B. Relay für Single-Page-Applikationen.

GraphQL besitzt eine breite, aktive Community und Server- und Client-Implementierungen für viele Programmiersprachen und Frameworks. Implementierungen für Java, PHP, Python, Go oder C# sind nur die typischsten Vertreter ihrer Art.

Die Lernkurve von GraphQL ist einerseits etwas steiler als die von Falcor und es müssen je nach verwendeter Sprache/Framework zusätzliche Bibliotheken eingebunden werden. Andererseits bieten gerade die vielen zusätzlichen Bibliotheken einen reichen Schatz verfügbarer Funktionalität, mit dem sich viele Herausforderungen aber auch Möglichkeiten von GraphQL leicht lösen/integrieren lassen.

gRPC

gRPC ist ein RPC Framework mit Fokus auf Skalierung und Durchsatz. Bei gRPC ruft eine Client-Anwendung eine Methode einer auf einem entfernten Server laufenden Anwendung auf. So, als ob diese entfernte Anwendung sich lokal auf derselben Maschine wie der Client befindet. Dem Client ist dabei die Signatur der Methode mit Parametern und Rückgabewerten bekannt. Das Konzept kennen wir bis hierher genauso von RPC

Auf der Server-Seite – also der entfernten Anwendung – läuft ein gRPC-Server, der das Interface der Methode oben implementiert. Auf Client-Seite gibt es für das Methoden-Interface einen gRPC Stub, mit dem die Client-Anwendung tatsächlich lokal kommuniziert.

Wo ist hier die API?

Die Beschreibung der von gRPC Client und Server genutzten Schnittstelle erfolgt bei gRPC standardmäßig mit Protocol Buffers als Interface Definition Language (IDL) und Übertragungsprotokoll. Die Schnittstelle wird in einer .proto-Textdatei gespeichert. Mit Hilfe von protoc wird der von gRPC Client und Server zu verwendende, sprachspezifische Code generiert.

service BeispielService {
  rpc HalloGRPC (HalloRequest) returns (HalloReply) {}
}

message HalloRequest {
  string name = 1;
}

message HalloReply {
  string nachricht = 1;
}

Anders als bei Falcor und GraphQL gibt es also eine explizite, zu definierende API.

The good, the bad …

✅❌ Es gibt einen expliziten Vertrag, der definiert und von Client und Server erfüllt werden muss. Habe ich eher nur lesende Zugriffe und muss meine Daten nicht explizit schützen, bedeutet das z.B. gegenüber Falcor einen Overhead. Ob das für oder gegen gRPC spricht, muss jeder für sich selbst herausfinden.

✅ leicht erlernbar – protobuf ist einfach zu verstehen

✅ relativ plattform- bzw. sprachunabhängig

✅ übertragen werden Nachrichten anstatt Ressourcen + Verben (REST)

✅ gRPC kann – per PlugIn – um Gateway-Funktionalität erweitert werden

✅ Während GraphQL und Falcor auf eher für hierarchisch aufgebaute Daten geeignet ist, besteht diese Einschränkung für gRPC nicht. Fähigkeiten für Suche und Parametrisierung einzubauen, haben wir bei gRPC ebenfalls selbst in der Hand.

❌ Obwohl gRPC wegen seines geringen Overheads als schnelles Protokoll gilt, bietet es aus sich heraus keine Unterstützung für die Reduzierung von Latenz durch Zusammenfassung von Anfragen, wie z.B. in Falcor durch den Ein-Modell-Ansatz. Ist meine Netzwerkverbindung ausgelastet, ist meine gRPC-Anwendung potenziell langsam.

Für wen lohnt sich gRPC?

gRPC bietet Services eine einfache Möglichkeit, Daten zwischen verschiedenen Umgebungen und Standorten auszutauschen. Load Balancing, Logging, Tracing oder Health Checks können per PlugIn konfiguriert werden. Der Fokus von gRPC liegt eher in der Server-zu-Server-Kommunikation. gRPC-Schnittstellen, die für ein Frontend bereitgestellt werden, scheinen eher unüblich zu sein.

gRPC wird von mehr Programmiersprachen unterstützt als GraphQL, z.B. Java, PHP, Python, Go, C# und C++. Die Verbreitung von gRPC – sowohl bei Organisationen als auch bei Contributoren – ist aber geringer als die von GraphQL. Das gRPC-Gateway bietet mindestens den Ansatz ohne zusätzliche Tools eine API-Ökonomie aufzubauen. Ob dies tatsächlich ausreichend ist, hängt vom jeweiligen Kontext ab.

Die gRPC-Lernkurve ist wegen Einfachheit von gRPC und der Unterstützung bei der Generierung von Schnittstellenartefakten durch protoc sehr steil. Bis das erste laufende Beispiel mit den meisten grundlegenden Konzepten von gRPC steht, vergehen nur wenige Minuten.

Pure JSON – Request, Response, Confirm

Pure JSON basiert auf der Idee, zwischen Client und Server vorrangig mit Hilfe des Websocket-Protokolls und unter Benutzung von JSON als Datenformat zu kommunizieren.

Websockets sind vereinfacht beschrieben Peer-to-Peer-Verbindungen zwischen Client und Server. Eine Websocket-Verbindung beginnt als HTTP(S)-Request und wird von Client und Server “geupgraded”. Eine einmal etablierte Verbindung bleibt bestehen, bis sie von Client oder Server mittels close() geschlossen wird. Dadurch wird der aus HTTP bekannte Overhead für die Neuetablierung von Verbindungen nach Beendigung einer Anfrage vermieden. Ach ja: Bei HTTP darf ein Server nicht ohne Weiteres Daten an einen Client übertragen, sondern nur auf Anforderung des Clients. Diese Einschränkung besteht bei Websockets ebenfalls nicht. Beide Partner dürfen mittels send() Daten übertragen.

Über JSON als Datenaustauschformat müssen wir nicht viele Worte verlieren; über nur mit JSON-Mitteln beschriebene Schnittstellen schon. Die Idee hinter Pure JSON ist die Verwendung von Verben, die den CRUD-Operationen entsprechen: create, retrieve (statt read), update und delete und zusätzlich flush, um den Server darüber zu informieren, dass der Client Daten aus seinem Speicher gelöscht hat. Umgekehrt muss der Server dem Client kein flush mitteilen. Als Response für eine der obigen Operationen liefert der Empfänger z.B. im Fall von create CREATED oder CREATED_FAIL zurück.

Der Response-Status wird mit Hilfe von Log-Knoten im JSON mit Log Level, Return Codes und Nachrichten beschrieben.

...
// Log
{ log_table :[
{ code_key : "400",
code_str : "Bad request",
level_int : 3, /* entspricht error */
level_str : "error",
log_id : "42",
user_msg : "ID is missing"
}
],
...
}
...

Statt dem aus der REST-Welt bekannten Request/Response wird die Verwendung von Request/Response/Confirm empfohlen. Der Client quittiert dem Server mittels Confirm damit die erfolgreiche Verarbeitung erhaltener Daten. Auf diesem Weg kann der Server den Status des Clients nachvollziehen und z.B. nach zeitaufwändiger Verarbeitung von Daten durch den Client die Quittung für eine Operation erhalten.

Wo ist hier die API?

Die API wird durch folgende Message-Struktur definiert, die für alle Nachrichten in genau dieser Form verwendet werden soll:

// Request
{ action_str : "retrieve", /* Operation */
 data_type : "APIBeispiel", /* Anwendungsspezifisch */
 log_table : [ /* Log Informationen, eher nicht im Request */ ],
 request_map : { /* Request Parameter / Payload */ },
 trans_map : { /* Meta-Informationen wie API-Version */ }
}

// (Indirect) Response
{ action_str : "RETRIEVED", /* Als Antwort auf retrieve */
 data_type : "APIBeispiel", /* Anwendungsspezifisch */
 log_table : [ /* Log Informationen */ ],
 response_map : { /* Payload */ },
 trans_map : { /* Meta-Informationen */ }
}

// Confirm
{ action_str : "done", /* Quittung an Server: Bin fertig! */
 data_type : "APIBeispiel", /* Anwendungsspezifisch */
 log_table : [ /* Log Informationen */ ],
 confirm_map : { /* Payload */ },
 trans_map : { /* Meta-Informationen */ }
}

Bei Verwendung von Websockets werden Daten beidseitig mit send() übertragen. Client und Server müssen so gestaltet sein, dass die in diesem Format übertragenen Verben, Datentypen und Parameter verarbeitet werden können.

Ein Indirect Response findet statt, wenn sich auf dem Server Daten verändert haben und diese an den Client übertragen werden, ohne dass der Client die Aktualisierung explizit angefordert hat.

The good, the bad …

✅❌ JSON an sich folgt keiner Document Type Definition wie z.B. XML. Bei Bedarf kann hier JSON-Schema Abhilfe schaffen.

✅ Das Konzept hinter Pure JSON ist relativ einfach und leicht verständlich. Es ist schließlich nur JSON.

❌ Uns ist keine echte Pure-JSON-Anwendung bekannt. Pure JSON scheint aktuell eher Konzept als Lösung zu sein.

✅ Mehrere Pure-JSON-Nachrichten können als JSON Array zusammengefasst und übertragen werden.

✅ Pure JSON ist nicht an ein Protokoll gebunden: Ob Websockets, HTTP oder SMTP verwendet wird, spielt nur eine untergeordnete Rolle.

❌ Die Validierung einer Pure-JSON-Schnittstelle und der mit ihr übertragenen Daten muss vom Client und vom Server selbst vorgenommen werden.

✅ JSON ist per Definition sprach- und plattformunabhängig.

✅ Es werden in erster Linie Nachrichten statt Ressourcen à la REST verwendet.

Für wen lohnt sich Pure JSON?

Pure JSON APIs sind nicht an Websockets gebunden. Statt Websockets kann z.B. HTTP verwendet werden und dabei nur die POST Operation, um z.B. konsistent zu Websocket send() zu sein. Das ist ganz bestimmt nicht RESTful, aber genau darum ging es ja in diesem Blog.

Wo Echtzeit-Übertragung von Daten eine Rolle spielt, sind Websockets ein Mittel der Wahl, weil die Latenz für die Etablierung einer Verbindung gespart wird und so z.B. echtes Streaming von Daten möglich wird.

Das auf Node.JS basierende socket.io ist wohl die verbreitetste Websocket-Implementierung. Alternativen zu socket.io finden sich in vielen Programmiersprachen wie Java, Python, PHP, Go oder Scala. Keine der Implementierungen ist an die Verwendung von JSON gebunden.

Pure JSON API sind potenziell in jeder Sprache anwendbar, die JSON versteht. Sich mit der Idee auseinanderzusetzen, lohnt sich in jedem Fall.

Finally …

Wem die Einschränkungen von REST, also z.B.

  • strikte Verwendung von Ressourcen
  • nicht in jedem Fall passende Verben, PUT vs. POST
  • nicht in jedem Fall geeignete Response Codes
  • aufwändiges Debugging (Verb, Response Code, Payload, Header, eingebettete Fehlermeldungen etc.)

schmerzen, kann sich mit den in diesem Post beschriebenen Alternativen auseinandersetzen und prüfen, ob diese die im jeweiligen Kontext bestehenden Rahmenbedingungen besser erfüllen als REST.