Vor dem Event ist nach dem Event

Zend Framework 2: Einführung in den EventManager
Kommentare

Der EventManager war eine der größten Neuerungen, die das Zend Framework 2 mit sich brachte. Die Komponente war für die Entwicklung vom ZF2 so wichtig, dass Sie sogar für das ZF1 zurückportiert wurde. Mit dieser neuen Komponente ist es seitdem möglich, nicht nur sehr einfach in den MVC-Prozess einzugreifen, sondern auch eigene Klassen und Services Event-basiert erweitern zu können. Dieser Artikel zeigt verschiedene Einsatzmöglichkeiten anhand von einigen ausgewählten Beispielen auf, die Ihnen Ideen für eigene Umsetzungen bieten sollen.

Damit Sie die Beispiele selbst nachvollziehen können, steht eine Beispielanwendung (zu Zend Framework 2) auf GitHub für Sie bereit. Sie können das Projekt wie folgt klonen (bitte ggf. die Verzeichnisse anpassen) und installieren:

> cd /home/devhost
> git clone htt ps://github.com/RalfEggert/phpmagazin.event-manager 
> cd phpmagazin.event-manager 
> php composer.phar selfupdate
> php composer.phar install
> sudo chmod 777 -R data/

Danach richten Sie noch einen Virtual Host für die Adresse phpmagazin.event-manager ein, der wie in Listing 1 aussehen könnte. Wenn Sie nun http://phpmagazin.event-manager/ in Ihrem Browser aufrufen, sollte die Seite ungefähr wie in Abbildung 1 aussehen. Nun können wir loslegen.

  ServerName phpmagazin.event-manager
  DocumentRoot /home/devhost/phpmagazin.event-manager/public/
  AccessFileName .htaccess
  SetEnv APPLICATION_ENV development

  
    Options FollowSymLinks
    AllowOverride All
  

  
    Options Indexes FollowSymLinks MultiViews
            AllowOverride All
            Require all granted
  

Abb. 1: Aufruf der Beispielanwendung in Zend Framework 2

Zuerst etwas Theorie …

Bei der ereignisgesteuerten Architektur (Event-driven Architecture) wird die Interaktion von lose gekoppelten Komponenten mithilfe von Ereignissen gesteuert. Dabei fällt den Beobachtern (Observer oder Listener) die Aufgabe zu, den lieben, langen Tag darauf zu warten, dass ein bestimmtes Ereignis angestoßen wird (Trigger an Event). Der ZendEventManager löst diese Aufgabe und stellt für das Zend Framework 2 die passende Infrastruktur bereit, um in einer Applikation eine ereignisgesteuerte Architektur implementieren zu können.

Dabei stellt das Zend Framework 2 mithilfe des Servicemanagers sicher, dass jedes Objekt immer eine eigene Instanz von ZendEventManager übergeben bekommt. Jedes Objekt, das das ZendEventManagerEventManagerAwareInterface implementiert und über den Servicemanager instanziiert wird, bekommt dabei automatisch diese neue Instanz des EventManagers übergeben. Für übergreifende Dinge steht der SharedEventManager bereit, deren Listener von den einzelnen EventManager-Instanzen ebenfalls ausgeführt werden, wenn ein Ereignis für einen definierten Listener ausgeführt worden ist.

… und dann die Praxis

Die Theorie klingt gut und schön, doch erst ein praktisches Beispiel hilft den meisten Einsteigern, das dahinterliegende Prinzip wirklich zu erfassen. Als Beispiel wählen wir einen Service, der eingehende Bestellungen speichern soll. Der Prozess des Bestelleingangs ist eigentlich simpel, doch meistens sollen weitere Aufgaben im Anschluss der Bestellung ausgeführt werden. Dies könnten der Versand eine Bestellbestätigung per E-Mail oder das Schreiben der Bestellung in eine Logdatei sein. Diese beiden Aspekte haben im eigentlichen Bestellvorgang nichts zu suchen und könnten sogar zeitversetzt ausgeführt werden, um den Bestellvorgang für den Kunden schneller abschließen zu können. Ein Beispiel für solch einen Bestellservice im Order-Modul der Beispielanwendung finden Sie in Listing 2.

setIdentifiers(array(__CLASS__));

$this->eventManager = $eventManager;
  }

  public function getEventManager()
  {
    return $this->eventManager;
  }

  public function saveOrder()
  {
    $productList = Factory::fromFile(
      APPLICATION_ROOT . '/module/Order/config/products.config.php'
    );

    $order = new ArrayObject(array(
        'order_id'   => Rand::getInteger(1000, 9999, true),
        'order_item' => $productList[array_rand($productList)],
    ));

    $result = $this->getEventManager()->trigger(
      'preOrder', __CLASS__, array('order' => $order)
    );

    if ($result->stopped()) {
      return false;
    }

    file_put_contents(
      APPLICATION_ROOT . '/data/order/' . $order->offsetGet('order_id'),
      serialize($order)
    );

    $this->getEventManager()->trigger(
      'postOrder', __CLASS__, array('order' => $order)
    );

    return $order;
  }
}

Die Klasse OrderService stellt eine geschützte Eigenschaft für eine Instanz von ZendEventManagerEventManager sowie die entsprechende Getter- und Setter-Methode bereit. In der Methode setEventManager() wird zudem noch der Name der Klasse als Identifier registriert. Der Bestellvorgang ist in der saveOrder()-Methode zu finden. Der Einfachheit halber wird zu Beginn eine zufällige Bestellung erstellt. Danach wird der EventManager angewiesen, das preOrder-Event mithilfe des Klassennamens als Identifier anzustoßen. Dabei wird auch die erstellte Bestellung als Parameter übergeben. Wenn bei einer dieser vorgeschalteten Aufgaben ein Fehler aufgetreten ist, der die Weiterverarbeitung beendet hat, dann wird die saveOrder()-Methode als fehlerhaft abgebrochen. Ansonsten wird die Bestellung gespeichert und im Anschluss das postOrder-Event für den OrderService angestoßen. Zum Schluss gibt die Methode die generierte Bestellung zurück.

Damit ist unser OrderService bereits einsetzbar. Die weiteren Aspekte, die für die Speicherung einer Bestellung ausgeführt werden sollen, können nun in die Verantwortung des EventManagers übergeben werden. Diesen injizieren wir sauber mithilfe einer Factory, die in Listing 3 zu sehen ist.

get('OrderListener');

    $eventManager = $serviceLocator->get('EventManager');
    $eventManager->attachAggregate($orderListener);

    $orderService = new OrderService();
    $orderService->setEventManager($eventManager);

    return $orderService;
  }
}

Darin werden über den Servicemanager ein OrderListener und eine Instanz des EventManagers angefordert. Wie eingangs erwähnt, erhalten wir an dieser Stelle eine frische Instanz von ZendEventManagerEventManager. Diese bekommt den OrderListener injiziert, den wir uns als Nächstes anschauen werden. Dieser in Zend Framework 2 vorkonfigurierte EventManager wird dann zuletzt in den OrderService injiziert.

Aufmacherbild: <a href=“http://www.istockphoto.com/photo/human-mind-13485370″ title=“Human mind – Stock Image von iStockphoto / Uhrheberrecht: adventtr “ class=“elf-external elf-icon elf-external elf-icon“ rel=“nofollow nofollow“>Human mind – Stock Image von iStockphoto / Urheberrecht: adventtr 

Ein Listener muss gut zuhören können

Für den OrderListener wählen wir die Variante mit einer ListenerAggregate-Klasse. Dies hat den Vorteil der Wiederverwendbarkeit. Zudem stehen die einzelnen Listener-Methoden im Gegensatz zu Closures für die Ausführung beim Triggern eines Events nicht unnütz im Speicher, wenn sie nicht gebraucht werden. Listing 4 zeigt den OrderListener, der von unserem OrderService verwendet wird. Wir werfen an dieser Stelle zuerst einen Blick auf die attach()-Methode. Darin werden für das postOrder-Event zwei Listener und für das preOrder-Event ein Listener registriert:

  • sendConfirmation() soll nach dem Speichern der Bestellung eine Bestätigung versenden.
  • logOrder() soll die Bestellung nach dem Speichern ins OrderLog schreiben.
  • checkStock() soll den Warenbestand vor dem Speichern der Bestellung prüfen.

Durch die Angabe der Prioritäten wird sichergestellt, dass logOrder() vor sendConfirmation() ausgeführt wird. Die detach()-Methode stellt sicher, dass die Listener auch wieder entfernt werden, wenn der EventManager im Zend Framework 2 beendet wird. Des Weiteren finden wir in der Klasse noch Setter- und Getter-Methoden für einen Transportservice von ZendMail sowie für eine Instanz von ZendLogLogger, die bei der Initialisierung vom OrderListener von außen sauber injiziert werden.

Die Listener-Methode checkStock() simuliert an dieser Stelle die eigentliche Prüfung des Warenbestands mithilfe eines Zufallswerts. Das Entscheidende ist der Aufruf der stopPropagation()-Methode, die festlegt, dass die weitere Ausführung der restlichen Listener nicht ausgeführt werden soll. Die Prüfung dieser Rückgabe finden Sie in Listing 2 in der saveOrder()-Methode nach der Ausführung des preOrder-Events. In der Listener-Methode sendConfirmation() wird zuerst der übergebene Parameter mit der Bestellung geholt. Dann wird eine E-Mail-Nachricht mit ZendMailMessage erstellt und mit dem injizierten Transportservice versandt. Auf die Konfiguration und die entsprechende Factory für diesen Transportservice möchten wir an dieser Stelle nicht im Detail eingehen. Sie finden beides bei Interesse im Application-Modul. In der letzten Listener-Methode logOrder() wird ebenfalls auf die übergebene Bestellung zugegriffen, um diese in das OrderLog zu schreiben. Die Konfiguration vom OrderLog liegt ebenfalls nicht im Fokus dieses Artikels. Bei Interesse können Sie dies in der Konfigurationsdatei des Order-Moduls nachschauen.

Der Vollständigkeit halber werfen wir noch einen Blick auf die Factory für den OrderListener, den Sie in Listing 4 sehen. Darin werden der Transportservice und das OrderLog beim Servicemanager angefordert und danach in den OrderListener injiziert.

get('MailTransportFile');
    $orderLog      = $serviceLocator->get('OrderLog');
    $orderListener = new OrderListener();
    $orderListener->setMailTransport($mailTransport);
    $orderListener->setOrderLog($orderLog);

    return $orderListener;
  }
}

Im Zend Framework 2 – Bestellvorgang ausführen

Für die Ausführung des Bestellvorgangs ist der IndexController im Order-Modul zuständig. Dieser bekommt den OrderService in einer entsprechenden Factory injiziert, die Sie in Listing 5 anschauen können.

getServiceLocator();

    $orderService = $serviceLocator->get('OrderService');

    $controller = new IndexController();
    $controller->setOrderService($orderService);

    return $controller;
  }
}

Da wir die gesamte Funktionalität in unserem OrderService und dem OrderListener gekapselt haben, ist der IndexController selbst recht übersichtlich (Listing 6).

orderService = $orderService;
  }

  public function getOrderService()
  {
    return $this->orderService;
  }

  public function indexAction()
  {
    $newOrder = $this->getOrderService()->saveOrder();

    return new ViewModel(
      array(
        'newOrder' => $newOrder,
      )
    );
  }
}

Neben der Setter- und der Getter-Methode für den OrderService ist nur eine indexAction()-Methode zu finden. Darin wird die saveOrder()-Methode aufgerufen und das Ergebnis dann zwecks Ausgabe an den View übergeben. Bei diesem Aufruf werden dann im Hintergrund die einzelnen Listener-Methoden von unserem OrderListener ausgeführt.

Wenn Sie das Beispielprojekt installiert haben, können Sie den Bestellvorgang nun durch Aufruf der Seite ht tp://phpmagazin.event-manager/order in Ihrem Browser anschauen. Wenn die Prüfung des Warenbestands und damit das Speichern der Bestellung erfolgreich war, sollten Sie eine Darstellung wie in Abbildung 2 erhalten.

Abb. 2: Erfolgreiche Bestellung in Zend Framework 2

War die Prüfung des Warenbestands erfolglos, sollten Sie einen entsprechenden Hinweis erhalten. Nach dem Aufruf sollten Sie in der Datei /data/log/order.log das OrderLog, im Verzeichnis /data/mail/ die versandten E-Mails und im Verzeichnis /data/order/ die gespeicherten Bestellungen finden.

Mit diesem Beispiel haben Sie alle wesentlichen Funktionalitäten des EventManagers aus dem Zend Framework 2 kennengelernt. Wir haben eine EventManager-Instanz erstellt und konfiguriert und ein Listener-Aggregat mit mehreren Listener-Methoden bereitgestellt. Wir haben Events ausgelöst und diverse Aufgaben abgearbeitet. Zudem haben wir eine Prüfung implementiert, wenn eine Listener-Methode auf ein Problem gestoßen ist, das einen Abbruch der Weiterverarbeitung des Bestellvorgangs unmöglich macht. In einer „richtigen“ Anwendung wäre zu überlegen, die einzelnen Listener-Methoden aus dem OrderListener-Aggregat zu extrahieren, um diese einfacher testen zu können und um den OrderListener nicht mit zu vielen verschiedenen Aufgaben zu überfrachten.

Zend Framework 2 – MVC-Events

ZendEventManagerEventManager lässt sich jedoch nicht nur für eigene Zwecke verwenden. Viele Komponenten im Zend Framework 2 nutzen ebenfalls den EventManager, um den Ablauf der einzelnen Teilkomponenten steuern zu können. Dazu zählen unter anderem ZendCache, ZendDb, ZendSession, ZendView und vor allem ZendMvc. Besonders ZendMvc macht sehr intensiven Gebrauch vom EventManager, um die Initialisierung und Verarbeitung einer Zend-Framework-2-Anwendung steuern und beliebig erweiterbar machen zu können. Der Kern des Event Handlings bildet das ZendMvcMvcEvent, das während der Verarbeitung einer Anfrage an alle Listener-Methoden übergeben und verarbeitet wird. Das MvcEvent hat unter anderem Zugriff auf das ZendMvcApplication-Objekt, das Result-Objekt, das Response-Objekt, den Router, das Ergebnis des Dispatchings vom Action-Controller und das ViewModel, das üblicherweise das Layoutskript repräsentiert. Im MvcEvent sind eine Reihe von Events definiert, die nacheinander verarbeitet werden (Tabelle 1).

Tabelle 1: MvcEvents

Der gesamte Ablauf bei der Verarbeitung einer Anfrage ist im Zend Framework 2 somit komplett Event-basiert. Dies hat den großen Vorteil, dass Sie in jeder Stufe der Verarbeitung einsteigen können, um eigenen Programmcode ausführen zu können. Die Möglichkeiten dafür sind vielfältig:

  • Nach dem route-Event könnten Sie die Lokalisierung der Anwendung festlegen.
  • Nach dem route-Event könnten Sie die einzelnen Elemente des Routings übersetzen lassen, damit die gefundene Route beim Dispatching korrekt verarbeitet werden kann.
  • Vor dem dispatch-Event könnten Sie eine Authentifizierung und Autorisierung einfügen, um nicht erlaubte Zugriffe zu sperren oder umzuleiten.
  • Vor dem dispatch.error-Event könnten Sie die geworfene Exception abfangen, loggen und weiterverarbeiten.
  • Nach dem render-Event könnten Sie die generierte Ausgabe des HTML-Codes minifizieren, um Bandbreite zu sparen.
  • Vor dem finish-Event könnten Sie einen Zeitstempel für die Dauer der Ausführung der aktuellen Seite anhängen.

Der Kreativität und Ihren konkreten Anforderungen sind an dieser Stelle kaum Grenzen gesetzt. Wir werden im Folgenden zwei dieser Ideen an einem praktischen Beispiel umsetzen.

Lokalisierung nach „route“-Event festlegen

Eine gängige Anforderung an moderne Webanwendungen ist es, auf Basis des Routings die Sprache festlegen zu können. Der Sprachschlüssel kann dabei z. B. in einer Subdomain oder als Teil des URL enthalten sein. Mithilfe des route-Events können wir auf einfache Weise die Bestandteile der aktuellen Route untersuchen und somit die Sprache festlegen. Als Erstes benötigen wir dafür eine Route, die einen Sprachschlüssel enthält (Listing 7).

return array(
  'router' => array(
    'routes' => array(
      [...]

      'i18n' => array(
        'type'    => 'Segment',
        'options' => array(
          'route'    => '/i18n[/:lang]',
          'defaults' => array(
            'controller' => 'i18n',
            'action'     => 'index',
            'lang'       => 'de',
          ),
          'constraints' => array(
            'lang'  => '[a-z]{2}'
          ),
        ),
      ),
    ),
  ),

  [...]
);

Bei der Beispielroute i18n ist die Sprache als optionaler Parameter angehängt. Der zugehörige Action-Controller enthält keinerlei Besonderheiten, sodass wir uns direkt dem entsprechenden View-Skript zuwenden können, das in Listing 8 abgebildet ist.

headTitle('Sprache prüfen');

$date = $this->dateFormat(
  new DateTime(),
  IntlDateFormatter::LONG,
  IntlDateFormatter::NONE
);
$time = $this->dateFormat(
  new DateTime(),
  IntlDateFormatter::NONE,
  IntlDateFormatter::LONG
);
$amount = $this->currencyFormat(123456.78);

$localeList = array('de', 'en', 'fr', 'it');
?>

Aktuelle Sprache:Datum:

Zeit:

Betrag:

  url('i18n', array('lang' => $locale)); ?>
  <a href="" class="btn btn-primary">
    
  

Darin werden das aktuelle Datum, die aktuelle Zeit und ein Wert in einer Währung ausgegeben. Zusätzlich werden für einen einfachen Sprachwechsel die Links auf dieselbe Seite für die Sprachen Deutsch, Englisch, Französisch und Italienisch ausgegeben.

Die eigentliche Aufgabe der Festlegung der aktuellen Sprache übernimmt der I18nListener, der in Listing 9 zu finden ist.

listeners[] = $events->attach(
      MvcEvent::EVENT_ROUTE,
      array($this, 'setupLocalization'),
      -100
    );
  }

  public function detach(EventManagerInterface $events)
  {
    foreach ($this->listeners as $index => $listener) {
      if ($events->detach($listener)) {
        unset($this->listeners[$index]);
      }
    }
  }

  public function setupLocalization(EventInterface $e)
  {
    $lang     = $e->getRouteMatch()->getParam('lang', 'de');
    $currency = $lang == 'en' ? 'USD' : 'EUR';

    Locale::setDefault($lang);

    $serviceManager = $e->getApplication()->getServiceManager();
    $viewManager    = $serviceManager->get('viewmanager');

    $currencyHelper = $viewManager->getRenderer()->plugin('currencyformat');
    $currencyHelper->setCurrencyCode($currency);
    $currencyHelper->setShouldShowDecimals(true);
  }
}

Darin wird in der attach()-Methode festgelegt, dass die Listener-Methode setupLocalization() nach dem Routing ausgeführt werden soll. Der negative Parameter stellt sicher, dass die Listener-Methode erst nach dem route-Event ausgeführt wird. In der Methode setupLocalization() wird zuerst der Parameter für die Sprache von der aktuellen Route angefordert. Ist der Parameter lang nicht gesetzt, wird de als Standardwert festgelegt. Im Anschluss wird auf Basis der Sprache die aktuelle Währung ausgewählt. Nun wird mithilfe von Locale::setDefault() die Sprache gesetzt. Zusätzlich wird die Instanz des CurrencyFormat-View-Helpers angefordert, um den Währungsschlüssel festzulegen.

Um den I18nListener im EventManager zu registrieren, wird die onBootstrap()-Methode des Application-Moduls verwendet. Dies ist sehr simpel, wie Listing 10 demonstriert.

getApplication()->getEventManager();
    $moduleRouteListener = new ModuleRouteListener();
    $moduleRouteListener->attach($eventManager);

    $eventManager->attachAggregate(new I18nListener());
  }

  [...]
}

Wir müssen den I18nListener lediglich instanziieren und dann dem EventManager hinzufügen. Das war es schon. Beim Aufruf der Seite ht tp://phpmagazin.event-manager/i18n in Ihrem Browser sollte Ihnen eine ähnliche Seite wie in Abbildung 3 angezeigt werden. Mithilfe der Sprachlinks können Sie die Ausgabe entsprechend ändern.

Abb. 3: Sprachausgabe

Diese Implementation ist natürlich noch unvollständig. Zum einen muss jede Route den Sprachschlüssel enthalten. Zum anderen haben wir den Aspekt der Übersetzung der Texte mit ZendTranslate vollkommen außen vor gelassen. Beide Aspekte sind nicht Teil dieses Artikels, dennoch sollte das Prinzip der Abfrage von Parametern aus dem Routing nach der Ausführung des route-Events nun klarer sein.

Zeitstempel nach „finish“-Event einfügen

Als letztes Beispiel noch eine eher triviale Fingerübung, die aber eine weitere interessante Einsatzmöglichkeit der MVC-Events aufzeigt: Wir möchten einfach am Ende der Seite einen kleinen Zeitstempel integrieren, der die Laufzeit der Seitenverarbeitung in Millisekunden ausweist. Hierfür haben wir zum Start der Zeitmessung in der index.php-Datei im /public/-Verzeichnis eine Konstante wie folgt definiert:

define('TIMESTAMP_START', microtime(true));

Der ViewListener, der sich um die Einbindung des Zeitstempels in den HTML-Code kümmert, ist in Listing 11 zu sehen.

<?php
/**
 * Zend Framework 2 - PHP-Magazin Event-Manager
 *
 * Beispiele für ZF2 Event-Manager
 *
 * @package    Application
 * @author     Ralf Eggert 
 * @link       http://w ww.ralfeggert.de/
 */

namespace ApplicationListener;

use ZendEventManagerEventInterface;
use ZendEventManagerEventManagerInterface;
use ZendEventManagerListenerAggregateInterface;
use ZendMvcMvcEvent;

class ViewListener implements ListenerAggregateInterface
{
  protected $listeners = array();

  public function attach(EventManagerInterface $events)
  {
    $this->listeners[] = $events->attach(
      MvcEvent::EVENT_FINISH,
      array($this, 'addTimeStamp'),
      -100
    );
}

  public function detach(EventManagerInterface $events)
  {
    foreach ($this->listeners as $index => $listener) {
      if ($events->detach($listener)) {
        unset($this->listeners[$index]);
      }
    }
  }

  public function addTimeStamp(EventInterface $e)
  {
    $body = $e->getResponse()->getContent();

    $html = '
‚; $html.= ‚‚; $html.= round(microtime(true) – TIMESTAMP_START, 5) * 1000 . ‚ms‘; $html.= ‚‚; $html.= ‚
';

    $e->getResponse()->setContent(
      str_replace('', $html . '', $body)
    );
  }
}

Darin wird die Listener-Methode addTimeStamp() im Anschluss an das finish-Event registriert. In dieser Methode machen wir es uns zunutze, dass der bereits gerenderte HTML-Code der Seite über das Response-Objekt abrufbar und änderbar ist. Wir holen uns den Inhalt, erstellen einen kleinen HTML-Block für die Ausgabe der Zeit und fügen diese vor dem schließenden Tag für den Footer ein. Den geänderten HTML-Code übergeben wir dann wieder an das Response-Objekt. Schließlich registrieren wir auch den ViewListener in der onBootstrap()-Methode des Application-Moduls, wie es schon in Listing 11 für den I18nListener gezeigt wurde.

Zend Framework 2 / ZF2 Fazit

Wir haben in diesem Artikel die theoretischen Hintergründe des EventManagers beleuchtet und die verschiedenen Möglichkeiten in der Praxis anhand eines Beispiels für einen OrderService kennengelernt. Dabei haben wir mehrere Listener-Methoden implementiert und auch fehlerhafte Prüfung im preOrder-Event so abgefangen, dass der Bestellprozess bei Bedarf auch abgebrochen werden kann. Zusätzlich haben wir den Einsatz des EventManagers in der ZendMvc-Komponente erläutert und zwei kleinere Beispiele für das Einklinken in den MVC-Prozess umgesetzt. ZendEventManager bietet jedoch noch weitere Möglichkeiten, wie das Erstellen eigener Event-Objekte oder Wildcard Listener, für die sich ein weiterer Blick in das Referenzhandbuch lohnen würde.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -