Content-Type Negotiation mit Symfony2

Avatar von Paul Seiffert

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-Typ video/mp4, als auch in application/mp4 vorkommt. Aus dem Anhängen des Typs würden dann Action-Namen wie indexActionMp4Video und indexActionMp4Application 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>
Avatar von Paul Seiffert

Kommentare

9 Antworten zu „Content-Type Negotiation mit Symfony2“

  1. Avatar von Bleistift

    In RoR is das meines Erachtens eleganter gelöst: Es wird automatisch das passende View-Script geladen. Die Action ist immer die selbe, wobei man dann innerhalb der Action noch Unterscheidungen vornehmen kann (benötigt man aber in 99% der Fälle nicht).

  2. Jungs .. ihr kennt knpbundles.com? dann solltet ihr auch mal ueber FOSRestBundle gestolpert sein ..
    https://github.com/FriendsOfSymfony/FOSRestBundle

    was wiederum FOSRest fuer content negotiation verwendet.
    https://github.com/FriendsOfSymfony/FOSRest

    Nun gut die implementierung dort ist ziemlich „lala“ um es nett auszudruecken.
    https://github.com/FriendsOfSymfony/FOSRest/blob/master/Util/FormatNegotiator.php

    Achja .. wo wir beim Thema sind:
    http://pooteeweet.org/blog/2154

    Ziel der Uebung waere es das ganze von einem Controller Listener in das Routing zu integrieren.

    Wie auch immer .. es waere recht sinnvoll wenn Ihr euch FOSRest anschaut, auf die schnelle geschaut scheint Ihr subtypes besser zu supporten als das Zeug was ich zusammengeschustert habe.

    Achja .. und eure Verzeichnisstruktur ist ein bisschen unorthodox.

  3. Sorry .. mein Kommentar war unnötig besserwisserisch.

  4. Hi Lukas,

    passt schon, alles gut! :)

    Wir sind nach wie vor an dem Thema dran und haben dein Feedback im Kopf.
    Klar, knpbundles kennen wir, vom FOSRestBundle hatte ich gehört, es mir ehrlich gesagt aber nie näher angesehen. Mittlerweile habe ich mir einen Eindruck geschaffen und sehe Vorteile, aber auch Nachteile.
    Gut finde ich auf jeden Fall den ViewHandler, mit einem solchen Ansatz könnten wir uns wohl viel Controller-Code sparen. Andererseits frage ich mich, ob man damit nicht zu viel (Serialisierung) vereinheitlicht und am Ende für jede Action eigene custom handler implementieren muss.

    Anfangs wollten wir (wie erwähnt) auch die Negotiation ins Routing integrieren, sind dabei jedoch nur in Probleme gelaufen. Wir bleiben aber dran.

    Vielleicht kann ich Dir hier gleich eine Rückfrage stellen: Im JMSDIExtraBundle wird ja der Standard-ControllerResolver mit einem eigenen ersetzt. Ich halte dieses Vorgehen für bedenklich, da es somit nicht möglich aus anderen Bundles das gleiche zu tun. Würde der JMS\DiExtraBundle\HttpKernel\ControllerResolver statt Vererbung Delegation nutzen, könnte man die ControllerResolver chainen, so nicht. Gibt es für solche Fälle best practices oder vielleicht auch einfach nur Erfahrungen?

    Viele Grüße,
    Paul

    1. Ja am ControllerResolver rum basteln ist nen bissel heikel und nicht wirklich als extension point gedacht. Dafuer sind eigentlich die Listener da.

      Das ganze ins Routing zu integrieren hat halt einfach den Vorteil, dass man dann auch sauber verschiedene Controller haben kann, z.B. fuer verschiedene Formate, aber viel wichtiger z.B fuer verschiedene API Versionen.

      Achja noch mal zu dem JMSDiExtraBundle ControllerResolver, notwendig wird das eigentlich nur weil nicht alle Leute Controller als Services definieren. Aber da auch Fabien Potencier Controller nicht als Services definiert sind das leider die meisten ..

      1. Der ControllerResolver diente mir hier auch mehr als Beispiel für meine Frage bzgl. dem Chaining oder Ersetzen von Services.

        Meinst du mit „ins Routing zu integrieren“, tatsächlich den Router bzw. seine Abhängigkeiten anzufassen oder das Routing über seine extension points zu erweitern? Bei der Implementierung mit Hilfe der kernel-Events fallen mir nämlich gleich mal eine Reihe von Stolperfallen ein.

    2. Ah und wegen der Sorge, wegen der Sorge, dass es „custom handler“ en mass braucht, dies wird durch die Moeglichkeiten vom JMSSerializerBundle sehr gut verhindert. Mit dem Bundle kann man jedem Objekt direkt „konfigurieren“ wie es sich serialisieren soll. Ist also nicht Aufgabe des Controller.

      1. Ok, das klingt sauber.

  5. Avatar von Tiago Brito
    Tiago Brito

    Hi!
    The article seems interesting, but next time I hope you write in English for yours world wide readers.

    Thank you!
    Tiago Brito
    from Portugal

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert


Für das Handling unseres Newsletters nutzen wir den Dienst HubSpot. Mehr Informationen, insbesondere auch zu Deinem Widerrufsrecht, kannst Du jederzeit unserer Datenschutzerklärung entnehmen.