Die Mächtigkeit und Einfachheit des HTTP wird in vielerlei Hinsicht oft unterschätzt. Der folgende Artikel stellt einen Aspekt vor, bei dem genau dies der Fall ist: Die Aushandlung des Formats, in welchem Daten per HTTP transportiert werden. Leider wird in den allermeisten HTTP-Applikationen über das Format der Antwort nicht verhandelt. Es werden beispielsweise GET-Requests kategorisch mit HTML beantwortet, ob ein Newsfeed als Atom oder RSS geliefert wird, entscheidet ein URL-Parameter. Auch ist das Kriterium dafür, mit JSON oder XML zu antworten, nicht zu selten, ob auf die Ressource via XMLHTTPRequest zugegriffen wird. Hierbei halten sich selbst moderne HTTP-Applikationen kaum an den Standard.
Das Problem
Unser Anwendungsbeispiel ist eine Applikation, in der alle Daten sowohl in Form von HTML-Dokumenten als auch in JSON ausgeliefert werden können. Genau für solche Zwecke sieht das HTTP den Request-Header Accept
vor. Ein HTTP-Client kann diesen nutzen, um dem Server mitzuteilen, welches Format er gerne in der Antwort vorfinden würde. Dabei kann er nicht nur ein einzelnes Format angeben, sondern auch mehrere und diese sogar priorisieren.
Die Theorie
Ein Accept
-Header könnte zum Beispiel wie folgt aussehen:
(für eine allgemeine Format-Beschreibung, liest man am besten den HTTP/1.1 – RFC 2616)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Übersetzt bedeutet das etwa:
Ich hätte gerne ein HTML- oder XHTML-Dokument. Wenn das nicht gehen sollte, möchte ich eines im XML-Format. Notfalls auch irgendetwas anderes.
Der Parameter q
, der optional an jeden angeforderten MIME-Typ angehängt und mit einem Fließkommawert zwischen0.0
und 1.0
belegt werden kann, legt aus Sicht des Clients die Gewichtung des jeweiligen Typs fest.
Wichtig: Nicht die Reihenfolge der durch Kommata getrennten Formate, sondern Ihre Gewichtung ergibt die Präferenzen des Clients!
Im oberen Beispiel haben die Formate text/html
(wobei text
der sogenannte Typ und html
der sogenannte Subtypist) und application/xhtml+xml
implizit die Gewichtung 1. Das Format application/xml
hat die Gewichtung 0.9 und alles andere 0.8. Eine korrekt implementierte HTTP-Applikation sollte bei einer solchen Anfrage versuchen, das am höchsten priorisierte Format zurückzuliefern und, falls dieses nicht verfügbar ist, den Client mit dem nächst niedriger priorisierten Format zu bedienen.
Umsetzungsideen
Um dies in unserer Beispiel-Applikation zu bewerkstelligen, war unser erster Ansatz, einfach in den Controllern eine Unterscheidung anhand des Accept
-Headers zu machen:
Diese Herangehensweise ist natürlich recht umständlich, da man in jeder Controller-Action eine Unterscheidung implementieren muss. Also war unsere nächste Idee, Symfonys Pseudo-Request-Parameter _format
zu nutzen, da dieser auch für das Routing verwendet werden kann (http://symfony.com/doc/current/book/routing.html#advanced-routing-example). Schnell erkannten wir, dass damit unser Vorhaben nur halb erfüllt wird, da Symfony diesen Parameter als Bestandteil der URL eines Requests vorsieht. Damit waren wiederum wir nicht einverstanden, da HTTP ja von Haus aus Content-Type-Negotiation implementiert und wir für jede unserer REST-Resourcen nur eine URI anbieten wollten. Die konkrete Idee war dann, den _format
-Parameter künstlich (mit einem Wert aus dem Accept
-Header) zu setzen und _format
beim Routing zu verwenden. Wir hatten uns überlegt, mehr als eine Route zu definieren, die das gleiche URL-Pattern mit jedoch unterschiedlichen Actions hat und anhand von Requirements über den _format
-Parameter entschieden wird, welche Route zu wählen ist.
Vorsicht: Das vorangegangene Beispiel funktioniert nicht!
Was wir leider nicht wussten, war, dass eine Route nur Requirements über Ihre URL-Parameter (und den speziellen_method
-Parameter, der die HTTP-Methode darstellt) beinhalten kann. Mit diesen zwei Routen wählte der Router daher immer die erste aus.
Um das zu umgehen, wollten wir dem Router beibringen, auch Anforderungen an andere Parameter zu evaluieren. Nach ein paar Nachforschungen stellte sich heraus, dass dieser Ansatz sehr viel mehr Aufwand und Schmerzen mit sich bringen würde, weshalb wir uns für das im Folgenden beschriebene Vorgehen entschlossen.
Die Lösung
Um es nun Entwicklern einfach zu machen, Daten in verschiedenen Formaten anzubieten, implementierten wir eine Lösung, welche pro möglichem Format eine Controller-Action vorsieht. In vielen Fällen werden diese Actions nur weitere Funktionen aufrufen, welche die Daten generieren und deren Rückgabewert dann im entsprechenden Format enkodieren. Diese Design-Entscheidung trägt jedoch potentiell dazu bei, dass die Komplexität von Controller Code erheblich verringert wird.
Als Namenskonvention für die Methoden, welche gleiche Daten in unterschiedlichen Formaten liefern, haben wir festgelegt, dass die Kurzbezeichnung des Formats (also der MIME-Subtyp) als Suffix an den jeweiligen Methodennamen gehängt wird. Zusätzlich zur Methode indexAction
entstünden so zum Beispiel auch noch indexActionJson
und indexActionHtml
. Für den seltenen Fall, dass Formate angefordert werden, die durch ihren MIME-Subtyp nicht eindeutig identifiziert werden können, wird außerdem noch der MIME-Typ angehängt.
Kollisionen könnten beispielsweise bei dem Subtypen
mp4
entstehen, der sowohl im MIME-Typvideo/mp4
, als auch inapplication/mp4
vorkommt. Aus dem Anhängen des Typs würden dann Action-Namen wieindexActionMp4Video
undindexActionMp4Application
resultieren, welche dann wieder unterscheidbar wären.
Die Prioritäten der Actions sind somit wie folgt festgelegt: Zuerst wird versucht die Methode<Actionname>Action<Subtyp><Typ>
aufzurufen. Falls diese nicht definiert ist, wird nach<Actionname>Action<Subtyp>
gesucht. Der Fallback ist wie gewohnt die Methode <Actionname>Action
, welche zu diesem Zweck auch immer implementiert werden muss. Die technische Lösung des Problems basiert auf einem EventListener, der auf Symfonys kernel.controller
-Event hört und anhand des Accept
-Headers die richtige Controller-Action wählt. Dabei sucht er in der durch den Router bestimmten Controller-Klasse nach Actions, die eines der angeforderten Formate zurückliefern. Wird eine solche gefunden, wird diese als Controller-Action gesetzt, anderfalls eine Response mit dem HTTP-Statuscode 406 gesendet.
Wiederverwendbarkeit
Um die umgesetzte Lösung anderen Entwicklern zugänglich zu machen, haben wir ein ContentTypeNegotiationBundle
geschaffen, welches einfach mit Composer einbindbar ist. Der dazugehörige Quellcode ist auf github.com veröffentlicht.
@Authors
- Simon Waibl <simon.waibl@mayflower.de>
- Paul Seiffert <paul.seiffert@mayflower.de>
Schreibe einen Kommentar