Version 2 (modified by tr, 6 years ago) (diff) |
---|
REST-Interfaces
Literaturhinweis
Tilkov et al.: REST und HTTP
Einführung
REST (Representational State Transfer) ist ein architektureller Ansatz des Software Engineerings mit dem Ziel, einheitliche Schnittstellen für verteilte Systeme zu schaffen.
Eine Haupteigenschaft von REST ist die Verwendung von HTTP-Verben zur Kodierung der Methoden (Befehle), die ein Client am Server ausführen kann. Insbesondere sind die Befehle nicht eng an die Objekte gekoppelt, wie das bei vielen herkömmlichen Schnittstellen der Fall ist.
Beispiel: Für das Abrufen eines Objekts vom Typ Student werden in herkömmlichen Schnittstellen häufig Requests der Form
http://mydomain.tld/getStudent.php?id=42
verwendet, wobei die Methode (get) im Namen des Skripts enthalten ist. Die Methode könnte aber beispielsweise ebenso retrieve (...retrieveStudent.php...) oder abrufen (...abrufenStudent.php...) usw. sein. Eine derartige Kodierung der Methode hat keine semantische Bedeutung sondern ist lediglich eine Lesehilfe zum Verständnis der Schnittstelle. Dies wird insbesondere problematisch, wenn für die Skripte keine sprechenden Namen verwendet werden.
In einem REST-Interface würde der URL
http://mydomain.tld/student/42
lauten und es würde die HTTP-Methode GET verwendet werden. Der URL ist aus drei Teilen aufgebaut:
- Adresse der Webanwendung: http://mydomain.tld/
- Objekttyp, auf den sich die Anfrage bezieht: student
- ID des Objekts: 42
Alle REST-Interfaces folgen im Wesentlichen dieser URL-Struktur, die ggf. etwas komplexer sein kann. Mit dieser Form ist aus dem Request eindeutig ableitbar, welches Ziel verfolgt wird. Jedes Objekt wird durch seinen URL weltweit eindeutig identifizierbar. Damit ist die Implementierung von REST-Schnittstellen nah am HTTP-Standard angesiedelt.
CRUD
Der Funktionsumfang vieler verteilter Informationssysteme kann nach dem CRUD-Schema vollständig beschrieben werden:
- Create: Erzeugen von Objekten
- Read: Lesen / Abrufen von Objekten
- Update: Aktualisieren / Ändern von Objekten
- Delete: Löschen von Objekten
Diesen Befehlen werden die HTTP-Methoden
- GET: Read
- POST: Create
- PUT: Update
- DELETE: Delete
zugeordnet.
REST-Interface mit Node.js
Im Folgenden wird ein REST-Interface für Artikel eines Shopsystems auf der Basis von Node.js entwickelt. Als Client zum Testen des Interfaces verwenden wir den Postman Client von Google Chrome.
Das Interface soll die folgenden Anforderungen erfüllen:
- Abruf eines Artikels mit GET /article/id
- Abruf aller Artikel mit GET /article/ (ohne id)
- Erzeugen eines neuen Artikels mit POST /article/
- Ändern eines Artikels mit PUT /article/id
- Löschen eines Artikels mit DELETE /article/id
Das Interface ist in der Datei shopserver.js implementiert.
Exkurs: serverseitige Datenhaltung
Grundsätzlich sollten serverseitig Daten stets in einer Datenbank gehalten werden. Für kleine Projekte oder Tests kann es jedoch sinnvoll sein, wenn die gesamten serverseitig verwalteten Daten immer als Objekte im RAM gehalten werden. Diese Objekte können dann einfach als JSON in eine Datei geschrieben und beim Start des Servers aus dieser Datei gelesen werden:
// Artikel im Shop var articles = []; // Einlesen aus Datei (falls vorhanden) // Auf diese Weise lässt sich sehr einfach eine serverseitige Datenhaltung // für sehr kleine Projekte implementieren. // __dirname ist das Verzeichnis des aktuell laufenden Skripts var filename = __dirname + '/articles.json'; //ggf. vorhandene Daten aus Datei einlesen try { var filedata = fs.readFileSync(filename); articles = JSON.parse(filedata); console.log(articles.length + " Datensätze eingelesen.") } catch(err) { // Datei exisitiert nicht, wir müssen nichts tun console.log("Keine Datensätze eingelesen.") }
Bei der Pfadangabe der Datei ist zu beachten, dass das aktuelle Verzeichnis von der verwendeten Node.js-Instanz abhängig sein kann. Daher sollte die Pfadangabe explizit gemacht werden. Die erfolgt durch Voranstellen des absoluten Pfades zum aktuellen Skript. Der absolute Pfad wird unter Node.js in der Variablen __dirname bereitgestellt:
// __dirname ist das Verzeichnis des aktuell laufenden Skripts var filename = __dirname + '/articles.json';
Bei jeder Aktualisierung des Arrays wird dieses neu in die Datei geschrieben und kann damit beim nächsten Serverstart wieder geladen werden:
function updateFile() { fs.writeFileSync(filename, JSON.stringify(articles)); }
Artikelobjekte
Die Objekte haben die Attribute name, preis und beschreibung:
{ name: 'Wasserwaage', preis: 14.99, beschreibung: 'Das Horizontalste seit der Erfindung des Salzsees.' }
Allgemeine fachliche Informationen aus dem Request lesen
Bevor die eigentliche Unterscheidung des Befehls entsprechend der HTTP-Methode erfolgt, werden der Objekttyp und ggf. die ID des Objekts aus dem Request gelesen:
function serve(req, res) { // welches Objekt? // Komponenten aus dem URL holen var parts = url.parse(req.url, true).path.split("/"); console.log(parts); // Ziel des Requests (Objekttyp) var target = parts[1]; // Wurde eine ID mitgeliefert? Wäre das dritte Element des Requests var id = -1; if (parts.length > 2 && parts[2] !== "") { id = parts[2]; } // Debug Ausgabe console.log('Ziel: ' + target + ' - ID: ' + id); // ... }
Sofern keine ID im Request enthalten ist, wird diese auf -1 gesetzt.
HTTP-Methoden
Alle Anfragen werden von der Laufzeitumgebung an die als Server registrierte Funktion serve weitergeleitet. Dort muss demnach zwischen den verschiedenen Methoden unterschieden werden. Dies kann mit einer switch-case Anweisung erfolgen:
function serve(req, res) { // ... console.log('Ziel: ' + target + ' - ID: ' + id); // Methode auswerten switch (req.method) { case "GET": // Artikel-Objekt(e) angefordert // ... break; case "POST": // neues Objekt erzeugen // ... break; case "PUT": // Objekt ändern // ... break; case "DELETE": // Objekt löschen // ... break; default: // Nicht unterstützte HTTP-Methode // ... break; } }
Im Folgenden werden die in den jeweiligen cases enthaltenen Codesegmente implementiert. Da vorerst nur ein Objekttyp (article) verarbeitet werden soll, ist es nicht notwendig, auf den jeweiligen Typ zu prüfen. Wir gehen also vorerst von freundlichen Clients aus. Die Struktur einer objektspezifischen Implementierung ist weiter unten beschrieben.
Abrufen mit GET
Für GET sollen die folgenden Anforerungen erfüllt werden:
- der URL enthält eine gültige ID: das zugehörige Objekt wird zurückgeliefert
- der URL enthält keine ID: der Server liefert alle Objekte
- der URL enthält eine ungültige ID: der Server antwortet mit dem Statuscode 404 (Not Found)
case 'GET': // Elemente der Antwort initialisieren (happy path) var status = 200; var returnType = 'application/json'; var message = ''; if (id !== -1) { // falls ID vorhanden: Artikel aus Array lesen var artIndex = findIndexById(id); if (artIndex !== -1) { // Artikel ausliefern message = JSON.stringify(articles[artIndex]); } else { // nicht gefunden: 404 status = 404; returnType = 'text/plain'; message = 'Artikel ' + id + ' nicht gefunden'; } } else { // keine ID: alle Artikel message = JSON.stringify(articles); } // Antwort senden res.writeHead(status, { 'Content-Type': returnType }); res.write(message); res.end(); break;
findIndexById(id)ist eine Hilfsfunktion, welche das Array articles nach einem Artikel mit der übergebenen ID durchsucht und den Index im Array zurückliefert. Eine Implementierung mit einer Map als Datenstruktur und der ID als Schlüssel wäre hier deutlich effizienter, aber auch umfangreicher.
function findIndexById(id) { for (var i = 0; i < articles.length; i++) { if (articles[i].id == id) { return i; } } // Falls es keinen Artikel mit der id gibt return -1; }
Zum Testen der GET-Methode des REST-Interfaces kann die Datei articles.json im Projektverzeichnis abgelegt werden. Bei den Tests mit dem Postman Client erhalten wir diese Ergebnisse:
Request: GET http://localhost:3000/article/2 Ergebnis: { "name": "Bohrmaschine", "preis": 75.99, "beschreibung": "Bohrt Beton wie Japanpapier.", "id": 2 } ---------------------------------------- Request: GET http://localhost:3000/article Ergebnis: [ { "name": "Wasserwaage", "preis": 19.99, "beschreibung": "Das Horizontalste seit der Erfindung des Salzsees.", "id": 1 }, { "name": "Bohrmaschine", "preis": 75.99, "beschreibung": "Bohrt Beton wie Japanpapier.", "id": 2 } ] ---------------------------------------- Request: http://localhost:3000/article/42 Ergebnis (Statuscode 404): Artikel 42 nicht gefunden
Erzeugen mit POST
Für die Methode POST muss nur eine Funktion implementiert werden: Sobald ein Artikelobjekt angeliefert wird, wird das Objekt an das Array angehängt und das erhaltene Objekt mit der neu vergebenen ID an den Client zurückgesendet. Hier gilt es wieder zu beachten, dass die Daten in Chunks gesendet werden könnten und daher entsprechende Eventhandler implementiert werden müssen:
case 'POST': // Objekt erzeugen // Zwischenspeicher für Chunks var data = ''; // Eventhandler für Chunk req.on('data', function(chunk) { data = data + chunk.toString(); }); // Eventhandler für Ende des Requests (alle Chunks eingetroffen) req.on('end', function() { // Objekt aus dem Request lesen var artikel = JSON.parse(data); // ID für neues Objekt festlegen artikel.id = getMaxId() + 1; // Artikel in das Array schreiben articles.push(artikel); // lokale Datei aktualisieren updateFile(); // Antwort schreiben res.writeHead(201, { 'Content-Type': 'application/json' }); res.write(JSON.stringify(artikel)); res.end(); }); break;
Der Statuscode 201 (Created) teilt dem Client mit, dass das Objekt erzeugt wurde. Die Funktion getMaxId() durchsucht das Array nach der höchsten ID, um die nächsthöhere ID an den neuen Artikel zu vergeben. Dies ist an dieser Stelle zur Illustration eingefügt. Effizienter wäre es, diese Funktion direkt nach dem Laden der Daten aus der Datei beim Serverstart einmal auszuführen, des Ergebnis in einer globalen Variablen zu speichern und fortan diese globale Variable bei jedem POST-Request zu inkrementieren.
function getMaxId() { var max = 0; for (var a of articles) { if (a.id > max) { max = a.id; } } return max; }
Der Test mit dem Postman Client ergibt:
Request: POST http://localhost:3000/article Request-Body: {"name":"Kombizange","preis":23.00,"beschreibung":"Beißt kräftig zu und hält sicher."} Ergebnis (Statuscode 201): { "name": "Kombizange", "preis": 23, "beschreibung": "Beißt kräftig zu und hält sicher.", "id": 3 }
Dem Objekt wurde die ID 3 hinzugefügt.
Ändern mit PUT
Das Ändern von Objekten mit PUT erfordert wieder etwas mehr Aufwand, da die vom Client mitgelieferte ID ungültig oder nicht vorhanden sein könnte. Im ersten Fall soll analog zum GET-Request mit 404 geantwortet werden, im zweiten Fall mit dem HTTP-Statuscode 400 (Bad Request).
Unter bestimmten Umständen muss die ID des Zielobjekts nicht im URL des Requests enthalten sein, nämlich dann, wenn die ID des Objekts Bestandteil des übermittelten JSON ist und vom Server aus dem Objekt gelesen werden kann. In unseren Beispiel ist dies der Fall. Der guten Form halber sollte man die ID jedoch immer im URL einkodieren, weswegen wir bei fehlender ID den Status 400 (Bad Request) zurückliefern.
Diese Anforderungen sollen umgesetzt werden:
- der URL enthält eine gültige ID: das zugehörige Objekt wird mit dem Inhalt des Requests geändert
- der URL enthält eine ungültige ID: der Server antwortet 404 (Not Found)
- der URL enthält keine ID: der Server antwortet 400 (Bad Request)
Wie bei POST werden PUT-Requests ggf. ebenfalls in Chunks gesendet, so dass entsprechende Eventhandler zu implementieren sind:
case 'PUT': // Objekt ändern // Zwischenspeicher für Chunks var data = ''; // Eventhandler für Chunk req.on('data', function(chunk) { data = data + chunk.toString(); }); // Eventhandler für Ende des Requests (alle Chunks eingetroffen) req.on('end', function() { var status = 200; var returnType = 'application/json'; var message = ''; if (id !== -1) { // Artikel ID im Request enthalten var artIndex = findIndexById(id); if (artIndex !== -1) { // Artikel mit der ID existiert: ersetzen var artikel = JSON.parse(data); articles[artIndex] = artikel; updateFile(); // geänderten Artikel zurückliefern message = JSON.stringify(articles[artIndex]); } else { // nicht gefunden: 404 status = 404; returnType = 'text/plain'; message = 'Artikel ' + id + ' nicht gefunden'; } } else { // Keine ID im Request --> 400: Bad Request status = 400; returnType = 'text/plain'; message = 'Fehlerhafter Request: Artikel ID fehlt.'; } // Antwort senden res.writeHead(status, { 'Content-Type': returnType }); res.write(message); res.end(); }); break;
Die Tests liefern folgende Ergebnisse:
Request: PUT localhost:3000/article/3 Request-Body: {"name":"Kombizange","preis":31.99,"beschreibung":"Beißt kräftig zu und hält sicher.","id":3} Ergebnis: { "name": "Kombizange", "preis": 31.99, "beschreibung": "Beißt kräftig zu und hält sicher.", "id": 3 } ---------------------------------------- Request: PUT localhost:3000/article Request-Body: beliebig Ergebnis (Statuscode 400): Fehlerhafter Request: Artikel ID fehlt. ---------------------------------------- Request: localhost:3000/article/42 Request-Body: beliebig Ergebnis (Statuscode 404): Artikel 42 nicht gefunden
Löschen mit DELETE
Beim Löschen sind ebenfalls drei Fälle abzudecken:
- der URL enthält eine gültige ID: das zugehörige Objekt wird gelöscht
- der URL enthält eine ungültige ID: der Server antwortet 404 (Not Found)
- der URL enthält keine ID: der Server antwortet 400 (Bad Request)
case "DELETE": // Objekt löschen var status = 200; var returnType = 'text/plain'; var message = ''; if (id !== -1) { // Artikel ID im Request enthalten var artIndex = findIndexById(id); if (artIndex !== -1) { // Artikel mit der ID existiert: löschen articles.splice(artIndex, 1); updateFile(); message = 'Artikel ' + id + ' gelöscht'; } else { // nicht gefunden: 404 status = 404; message = 'Artikel ' + id + ' nicht gefunden'; } } else { // Keine ID im Request --> 400: Bad Request status = 400; message = 'Fehlerhafter Request: Artikel ID fehlt.'; } // Antwort senden res.writeHead(status, { 'Content-Type': returnType, }); res.write(message); res.end(); break;
Die Tests mit Postman zeigen die folgenden Ergebnisse:
Request: DELETE localhost:3000/article/3 Ergebnis: Artikel 3 gelöscht ---------------------------------------- Request: DELETE localhost:3000/article/ Ergebnis (Statuscode 400): Fehlerhafter Request: Artikel ID fehlt. ---------------------------------------- Request: DELETE localhost:3000/article/42 Ergebnis (Statuscode 404): Artikel 42 nicht gefunden
Nicht unterstützte HTTP-Methoden
Bei den meisten Implementierungen ist es sinnvoll, dass der Client immer eine Antwort erhält, auch wenn eine nicht unterstützte HTTP-Methode verwendet wird. Dafür kann der Statuscode 405 (Method Not Allowed) zurückgesendet werden. In diesem Fall erwartet der Client den obligatorischen HTTP-Header Allow, in welchem die erlaubten Methoden mitgeteilt werden:
default: // Nicht unterstützte HTTP-Methode // 405: Method Not Allowed // Allow-Header MUSS enthalten sein: https://tools.ietf.org/html/rfc7231#section-6.5.5 res.writeHead(405, { 'Content-Type': 'text/plain', 'Allow': 'GET, POST, PUT, DELETE' }); res.write('HTTP-Methode ' + req.method + ' wird nicht unterstützt. Dies ist ein REST-Interface. Bitte nutzen Sie GET, POST, PUT, DELETE.'); res.end(); break;
Diese Verwendung von 405 in der beschriebenen Weise ist möglicherweise nicht ganz sauber, da sich der Fehlercode eigentlich darauf bezieht, dass eine bestimmte Ressource (ein Objekt) nicht mit der gewünschten Methode angesprochen werden darf (s. RFC7231). Da die Beschränkung auf die CRUD-Befehle in REST-Interfaces generell jedoch für alle Ressourcen gilt, erscheint die Verwendung von 405 auf die dargestellte Weise gerechtfertigt, zumal es an einer besser geeigneten Methode (z. B. Method Unknown oder Method Not Supported) im HTTP-Protokoll mangelt.
Der Test mit Postman ergibt das folgende Ergebnis:
Request: COPY localhost:3000/article/2 Ergebnis (Statuscode 405): Headers: Allow --> GET, POST, PUT, DELETE Body: HTTP-Methode COPY wird nicht unterstützt. Bitte nutzen Sie GET, POST, PUT, DELETE.
Unterstützung verschiedener Objekttypen
Im vorgenannten Beispiel werden ausschließlich Objekte vom Typ Artikel verarbeitet. Daher muss die Variable
// Ziel des Requests (Objekttyp) var target = parts[1];
nicht ausgewertet werden.
Sofern das REST-Interface - was bei komplexen Systemen durchaus üblich ist - mehrere Objekttypen verarbeiten kann, sind entsprechende Prüfungen einzubauen, welcher Objekttyp durch den Request verarbeitet werden soll. Dabei empfiehlt es sich für eine verbesserte Wartbarkeit, den Code zu entzerren und die diversen Fachlichkeiten in getrennte Funktionen und ggf. getrennte Quellcodedateien auszulagern:
switch (req.method) { case 'GET': switch (target) { case 'article': handleGetArticle(req, res); break; case 'customer': handleGetCustomer(req, res); break; case 'payment': handleGetPayment(req, res); break; default: // ... } break; case 'POST': // ... }