Ajax-Applikationen mit dem Zend Framework

Ajax wird heute in fast jeder modernen Webanwendung genutzt, bietet doch jedes gute Javascript-Framework mittlerweile einfache Möglichkeiten XHR-Anfragen abzusetzen. In diesem Artikel soll es aber nicht um die Client-Seite gehen, sondern darum, wie man auf der Server-Seite XHR-Anfragen erkennt und entsprechend darauf reagiert (Der Einfachheit halber rede ich hier nur von Ajax, weite Teile lassen sich aber natürlich auch auf den Bereich Webservices übertragen).

HTTP richtig nutzen

Clients können z. B. über Request-Header dem Server mitteilen welches Rückgabe-Format sie erwarten oder bereits an korrekt gesetzten Status-Codes erkennen, ob die Anfrage fehlgeschlagen ist oder nicht. Diese Art der Kommunikation setzt voraus, dass der Server korrekt antwortet. Deshalb sollte man eingehende Request-Header überprüfen und verarbeiten und entsprechende Response-Header setzen.

Allgemeine HTTP Request-Header
Content-Type Der Content-Type der Daten die an die Applikation geschickt werden, z.B. application/json
Accept Der Content-Type den der Client in der Antwort erwartet, z. B. application/json
Range Anzahl Bytes oder Einheiten (items) die in der Antwort enthalten sein sollen. Die W3C-Spezifikation führt nur Byte-Ranges explizit aus. dojox.data.JsonRestStore z. B. nutzt den Range-Header für das Paging in der Form Range: items=0-24 (Siehe Dokumentation).
Allgemeine HTTP Response-Header
Content-Type Der Content-Type der Daten die an den Client zurückgeschickt werden, z.B. application/json
Vary Der Vary-Header gibt dem Client oder Caching-Proxies Hinweise welche Header für das Caching verwendet werden sollen. Eine Resource kann verschiedene Darstellungsformen, abhängig vom gesendet Accept-Header, zurückliefern (z. B. eine Anfrage auf die URI /resource/id mit Accept: text/html liefert eine normal HTML-Seite und Accept: application/json auf die gleich URI liefert die Daten als JSON). Dadurch kann die URI nicht als eindeutige ID für das Caching verwendet werden. Ein Vary: Accept hilft dann dem Client oder Proxy die Antwort korrekt cachen zu können. Mehr dazu: Vary Header for RESTful Applications.
Content-Range Anzahl Bytes oder Einheiten (items) die zurückgegeben werden. Analog zum oben beschriebenen Range-Header kann man folgendes Format verwenden: Content-Range: items 0-24/66. Dabei wird der Start- und End-Offset (0-24) und die Gesamtanzahl der verfügbaren Einheiten angeben (66).
Allgemeine HTTP Status-Codes
201 (Created) Nach einem POST der eine Resource erstellt hat.
204 (No Content) Nach einem DELETE der eine Resource gelöscht hat.
400 (Bad Request) Validierungen sind fehlgeschlagen.
500 (Internal Server Error) Applikations-Fehler, z. B. wenn eine Exception geworfen wurde.

Ben Ramsey hat in seinem Blog eine kleine lesenswerte Reihe zu HTTP Status-Codes.

Request-Header verarbeiten mit einem Frontcontroller-Plugin

Wir erstellen uns ein Frontcontroller-Plugin, welches die eingehenden Header inspiziert und manipulieren das Request-Objekt entsprechend. So können wir in unseren Action-Controllern auf gewohntem Wege über das Request-Objekt auf die Parameter zugreifen.

<?php
/**
 * @see Zend_Controller_Plugin_Abstract
 */
require_once 'Zend/Controller/Plugin/Abstract.php';
 
/**
 * @see Zend_Controller_Request_Http
 */
require_once 'Zend/Controller/Request/Http.php';
 
/**
 * @package    App_Controller
 * @subpackage App_Controller_Plugin
 */
class App_Controller_Plugin_HttpHandler extends Zend_Controller_Plugin_Abstract
{
    /**
     * Called before Zend_Controller_Front enters its dispatch loop.
     *
     * @param  Zend_Controller_Request_Abstract $request
     * @return void
     */
    public function dispatchLoopStartup(Zend_Controller_Request_Abstract $request)
    {
        if (!$request instanceof Zend_Controller_Request_Http) {
            return;
        }
 
        $vary = array();
 
        // Process Accept-Header (the format parameter will later be used by the ContextSwitch helper)
        if (false !== ($accept = $request->getHeader('Accept'))) {
            if (strstr($accept, 'application/xml') && !strstr($accept, 'html')) {
                $request->setParam('format', 'xml');
                $vary[] = 'Accept';
            } elseif (strstr($accept, 'application/json')) {
                $request->setParam('format', 'json');
                $vary[] = 'Accept';
            }
        }
 
        // Process non-standard Content-Types
        $rawBody = $request->getRawBody();
        if (!empty($rawBody)) {
            if (false !== ($contentType = $request->getHeader('Content-Type'))) {
                if (strstr($contentType, 'application/xml') ||
                    strstr($contentType, 'text/xml')) {
                    require_once 'Zend/Config/Xml.php';
                    $config = new Zend_Config_Xml($rawBody);
                    $request->setPost($config->toArray());
                } elseif (strstr($contentType, 'application/json')) {
                    require_once 'Zend/Json.php';
                    $request->setPost(Zend_Json::decode($rawBody));
                }
            }
        }
 
        // Process Range-Header
        if (false !== ($range = $request->getHeader('Range'))) {
            if (preg_match('/items=(\d+)\-(\d+)/i', $range, $matches)) {
                $request->setParam('range_start', $matches[1]);
                $request->setParam('range_end', $matches[2]);
                $vary[] = 'Range';
            }
        }
 
        // Set Vary-Header
        if (count($vary) > 0) {
            $response = $this->getResponse();
 
            // Keep existing Vary headers
            foreach ($response->getHeaders() as $header) {
                if (strtolower($header['name']) != 'vary') {
                    continue;
                }
 
                $vary[] = $header['value'];
                $response->clearHeader($header['name']);
            }
 
            $response->setHeader('Vary', implode(', ', $vary));
        }
    }
}

Ausgabe-Formate variieren mit dem ContextSwitch-Action-Helper

Der standardmäßig im Zend Framework enthaltene ContextSwitch-Action-Helper bietet uns die Möglichkeit für einzelne Actions verschiedene Kontexte zu definieren.

<?php
class NewsController extends Zend_Controller_Action
{
    public function init()
    {
        $this->_helper->contextSwitch()
            ->setAutoJsonSerialization(false)
            ->addActionContext('list', 'json')
            ->initContext();
    }
 
    /**
     * List news items
     */
    public function listAction()
    {
        if ($this->_request->range_start || $this->_request->range_end) {
            $start = (int) $this->_request->range_start;
            $end   = (int) $this->_request->range_end;
 
            $paginator = $newsModel->getItemsAsZendPaginatorObject();
 
            $count = $paginator->getTotalItemCount();
            $limit = !$end ? $count : $end - $start;
            $end   = !$end ? $count : $end;
 
            $this->view->items = $paginator->getAdapter()->getItems($start, $limit);
 
            // Set Content-Range-Header
            $this->_response
                ->setHttpResponseCode(206) // Partial content header
                ->setHeader('Content-Range', 'items ' . $start . '-'. $end . '/' . $count);
        } else {
            $this->view->items = $newsModel->getAllItems();
        }
    }
}

Wir haben in diesem Controller also für die Aktion listAction den zusätzlichen Kontext json definiert und präparieren sie so für die Verwendung per Ajax.

Ein “context switch” wird durch den format-Request-Parameter augelöst. Der Parameter wird hier durch unser Plugin gesetzt, kann aber natürlich auch über die URL definiert werden (z. B. /news/list/format/json). Ist der format-Parameter nun also auf json gesetzt, wird automatisch der Kontext-Name an das View-Skript angehängt. Es wird statt list.phtml das View-Skript list.json.phtml gerendert. Außerdem wird der entsprechende Content-Type-Header application/json gesetzt und das Rendern der Layouts deaktiviert.

Ein kleiner Hinweis zum JSON-Kontext: Standardmäßig wird kein View-Skript gerendert sondern es werden einfach alle dem View-Objekt zugewiesenen Variablen serialisiert und direkt ausgegeben. Dieses Verhalten haben wir oben mit setAutoJsonSerialization(false) deaktiviert um eine bessere Kontrolle über die Ausgabe per View-Skript zu haben.

Das View-Skript list.json.phtml kann z. B. so aussehen:

<?php echo $this->json($this->items->toArray()); ?>

Neben dem ContextSwitch-Helper gibt auch noch den AjaxContext-Helper. Der einzige Unterschied ist, dass dieser nur bei einer Anfrage per Ajax ausgeführt wird (d. h. wenn der Header X-Requested-With: XMLHttpRequest gesetzt ist).

RESTvolle Controller

Ein weitere Möglichkeit per Ajax mit der Applikation zu kommunizieren ist REST. Das Zend Framework bietet dafür Zend_Rest_Route.

$router->addRoute(
    'rest',
    new Zend_Rest_Route($frontcontroller, array(), array(
        'default' => array(
            'news',
        )
    ))
);

Damit haben wir den NewsController im default-Modul “RESTvoll” gemacht. Der Controller erweitert dann Zend_Rest_Controller und muss dabei die Methoden indexAction(), getAction(), postAction(), putAction() und deleteAction() implementieren.

<?php
class NewsController extends Zend_Rest_Controller
{
    /**
     * The index action handles index/list requests; it should respond with a
     * list of the requested resources.
     */
    public function indexAction() { }
 
    /**
     * The get action handles GET requests and receives an 'id' parameter; it
     * should respond with the server resource state of the resource identified
     * by the 'id' value.
     */
    public function getAction() { }
 
    /**
     * The post action handles POST requests; it should accept and digest a
     * POSTed resource representation and persist the resource state.
     */
    public function postAction() { }
 
    /**
     * The put action handles PUT requests and receives an 'id' parameter; it
     * should update the server resource state of the resource identified by
     * the 'id' value.
     */
    public function putAction() { }
 
    /**
     * The delete action handles DELETE requests and receives an 'id'
     * parameter; it should update the server resource state of the resource
     * identified by the 'id' value.
     */
    public function deleteAction() { }
}

Der Vorteil hierbei ist, dass wir ein vorhersagbares API bereitstellen. Dojo zum Beispiel bietet mit der Kombination dojox.data.JsonRestStore und dojox.grid.DataGrid alles was man zur Implementierung von CRUD-Operationen braucht.

Links zum Thema