Symfony2: Event-Listener über Annotations registrieren

In den letzten Monaten habe ich hier leider nicht sehr viel geschrieben und freue mich daher um so mehr, wenn ich dann mal wieder über ein spanndendes Thema schreiben kann. So dann auch, wie ich finde, heute.

Es geht um zwei einfache, aber zugleich sehr nützliche Dinge, die das PHP-Framework Symfony2 mitbringt: Ein einfaches, flexibles Event-Handling und Annotations.

Für mich waren die letzten drei Wochen auch die ersten Gehversuche mit Symfony2 und bislang wurden alle meine Erwartungen bestätigt. Das Framework ist sehr flexibel, aber auch recht komplex. Wer allerdings andere MVC-Frameworks wie Zend kennt, der wird sich auch hier schnell zurecht finden.

So, genug Lob an der Stelle. Neben der positiven Eigenschafft eines Eventhandlingsystems fiel mir auch gleich die Art auf, wie sich in Symfony2 Controllerrouten mit Annotations konfigurieren lassen.

Leider habe ich nichts Vergleichbares für Eventlistener gefunden und die klassische Konfiguration über YML gefiel mir dann auch nicht mehr so recht. Das nahm ich dann also gleich mal als praxisnahes Kennenlernbeispiel her und habe mir da was gebaut.

Als erstes braucht es immer einen Auslöser, damit die Annotations verarbeitet werden. Dafür habe ich mich an das Event “kernel.request” gehängt, das noch vor dem Kontrolleraufruf verarbeitet werden kann.

Einen speziellen Listener hatte ich erst dazwischen, habe es dann später aber wieder etwas vereinfacht. Mein Code entspricht vielleicht nicht allen Symfony-Richtlinien, aber zum Arbeiten und Erklären reicht es aus.

Zum Verarbeiten der Annotations habe ich eine einfache Klasse aufgebaut und mich dabei an den “Route()”-Annotations orientiert. Die Methode “findClass()” ist aus bestehendem Code “recycled”, da sie anders schwerer erreichbar wäre.

<?php
namespace MyNamespace\Annotations;

use Doctrine\Common\Annotations\Reader;
use Symfony\Component\EventDispatcher\EventDispatcher;

class AnnotatedEventClassLoader
{
  private $reader;
  private $eventDispatcher;
  private $eventAnnotationClass  = 'MyNamespace\\Annotations\\Event';

  /**
   * Constructor
   *
   * @param    Reader $reader
   * @param    EventDispatcher $eventDispatcher
   * @return    void
   */
  public function __construct(
    Reader $reader, EventDispatcher $eventDispatcher) {
    $this->reader = $reader;
    $this->eventDispatcher = $eventDispatcher;
  }

  public function init() {
    $listenersDir = realpath(__DIR__ . '/../../Listener');

    $iterator = new \RecursiveIteratorIterator(
      new \RecursiveDirectoryIterator($listenersDir),
        \RecursiveIteratorIterator::LEAVES_ONLY);

    foreach ($iterator as $file) {
      if (!$file->isFile() || '.php' !== substr($file->getFilename(), -4)) {
        continue;
      }

      if ($class = $this->findClass($file)) {
        $reflClass = new \ReflectionClass($class);
        if ($reflClass->isAbstract()) { continue; }

        foreach($reflClass->getMethods() as $method) {
          $annotation = $this->reader->getMethodAnnotation($method, $this->eventAnnotationClass);
          if (!$annotation instanceof Event || empty($annotation)) { continue; }
          $this->registerEvents($annotation, $class, $method->getName());
        }
      }
    }
  }

  /**
   *
   * @param    \MyNamespace\Annotations\Event $eventAnnotation
   * @param    string|object $class
   * @param    string $method
   * @return   \MyNamespace\Annotations\AnnotatedEventClassLoader
   */
  public function registerEvents($eventAnnotation, $class, $method) {
    $listener = is_object($class) ? $class : new $class();

    foreach ($eventAnnotation->getEvents() as $event) {
      $this->eventDispatcher->addListener(
        $event, array($listener, $method), $eventAnnotation->getPriority()
      );
    }
    return $this;
  }

  /**
   * Returns the full class name for the first class in the file.
   *
   * @param string $file A PHP file path
   *
   * @return string|false Full class name if found, false otherwise
   */
  protected function findClass($file) {
    $class = false;
    $namespace = false;
    $tokens = token_get_all(file_get_contents($file));
    for ($i = 0, $count = count($tokens); $i < $count; $i++) {
      $token = $tokens[$i];

      if (!is_array($token)) { continue; }

      if (true === $class && T_STRING === $token[0]) {
        return $namespace.'\\'.$token[1];
      }

      if (true === $namespace && T_STRING === $token[0]) {
        $namespace = '';
        do {
          $namespace .= $token[1];
          $token = $tokens[++$i];
         } while ($i < $count && is_array($token) && in_array($token[0], array(T_NS_SEPARATOR, T_STRING)));
       }

       if (T_CLASS === $token[0]) {
         $class = true;
       }

       if (T_NAMESPACE === $token[0]) {
         $namespace = true;
       }
     }

     return false;
   }
}

Damit der Loader später auch seinen Dienst tut, muss er noch als Listener für das “kernel.request”-Event bekannt gemacht werden. Dafür habe ich die Datei “app/config/annotations.xml” mit folgendem Inhalt angelegt.

<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

  <parameters>
    <parameter key="myapp.event.loader.class">MyNamespace\Annotations\AnnotatedEventClassLoader</parameter>
  </parameters>

  <services>
    <service id="myapp.event.loader">
      <tag name="kernel.event_listener" event="kernel.request" method="init" />
      <argument type="service" id="annotation_reader" />
      <argument type="service" id="event_dispatcher" />
    </service>
  </services>
</container>

Die Konfiguration muss dann auch noch der AppKernel-Klasse mitgeteilt werden und damit ist der Loader eingebunden.

class AppKernel extends Kernel {
  // other methods...

  public function registerContainerConfiguration(LoaderInterface $loader) {
    // ...
    $loader->load(__DIR__ . '/config/annotations.xml');
  }
}

Annotations werden durch eigene Klassen verarbeitet und können dadurch mit beliebiger Logik versehen werden. Alles was einer Annotation als Parameter mitgegeben wird, bekommt die Klasse als Array im Constructor übergeben.

Für die Registrierung von Eventlistener brauchte ich nur die Events, die hier kommasepariert übergeben werden und gegebenenfalls noch die Priotität, in dem Fall optional. Die Klasse selber wird auch durch eine Annotation als solche gekennzeichnet.

<?php
namespace MyNamespace\Annotations;

/**
 * @Annotation
 */
class Event {
  private $events = array();
  private $priority = 0;

  public function __construct($options) {
    if (isset($options['value'])) {        
      $this->setEvents($options['value']);
    }

    $this->setPriority(
      isset($options['priority']) ? $options['priority'] : 0 );
  }

  public function getEvents() {
    return $this->events;
  }

  public function setEvents($events) {
    if(!is_array($events)) {
      $events = array_map('trim', explode(',', $events));
    }
    $this->events = $events;
    return $this;
  }

  public function getPriority() {
    return $this->priority;
  }

  public function setPriority($priority) {
    $this->priority = (int)$priority;
      return $this;
  }
}

Nun sollte alles an PHP-Code und Konfiguration an seinem Platz sein, aber wie funktioniert das jetzt genau?

Durch das Event wird der Loader benachrichtigt, dass die Listener initialisiert werden sollen. Dazu wird das Verzeichnis “Listener” nach Klassen mit Annotations durchsucht, welche dann von der Annotation-Event-Klasse verarbeitet werden. Innerhalb des Loader werden dann die Listener für die Events registriert und können von da an verwendet werden.

Die Untersuchung des PHP-Codes funktioniert mit Reflections, was ansich immer den Ruf hat langsam zu sein. Der AnnotationReader verfügt über ein eigenes Caching und macht sich daher zeitlich nicht negativ bemerkbar.

Der Loader durchsucht das gesamte Verzeichnis in beliebiger Tiefe nach Dateien, daher bleibt man in der Namensvergabe frei.

Ein Listener könnte dann zum Beispiel so aussehen:

<?php
namespace MyNamespace\Bundle\Listener\Deep\Folder\Structure\Test;

use MyNamespace\Annotations\Event;

class Listener {
    /**
     * @Event("foo.bar, bar.foo", priority="1")
     * @param type $event
     */
    public function onFooBar1($event) {
        echo __METHOD__;
    }

    /**
     * @Event("foo.bar", priority="20")
     * @param type $event
     */
    public function onFooBar2($event) {
        echo __METHOD__;
    }
}

Ein Aufruf aus dem Kontroller funktioniert dann wie gewohnt über den EventDispatcher, der fast überall als Service erreichbar ist.

public function indexAction() {        
  $this->get('event_dispatcher')->dispatch('foo.bar');
}

// oder
public function indexAction() {
  $someEvent = new SomeEvent();       
  $this->get('event_dispatcher')->dispatch('foo.bar', $someEvent);
}

Wie gesagt sind das meine ersten Versuche mit Symfony und der Code ist nicht optimiert. Die Funktionen des Loaders sollten noch weiter in Unterobjekte verteilt werden, damit das ganze noch flexibler wird und auch der gecodete Teil mit dem Verzeichnisnamen ist verbesserungfähig.

Ich konnte dabei wieder ein paar Sachen lernen und vielleich spart es ja dem einen oder anderen von euch etwas Zeit, falls ihr das braucht.

4 Kommentare

  1. Schöner Artikel :)
    1 kleinen Tipp hätte ich für dich.
    Anstatt die Events, in der Annotation, Komma getrennt zuschreiben kannst du den Array Syntax nehmen:
    @Event(events={foo.bar, bar.foo}).

    Und ich schätze in deinem AnnotationReader fehlt noch die Überprüfung ob die Methode von der Aktuellen Klasse ist oder von Ihrerer Elternklasse.

  2. Vielen Dank für deine Tipps, das kann dann im zweiten Schritt verbaut werden. :-)

  3. Kein Problem ;)

    Wenn du Fragen zu Symfony 2 oder Doctrine 2 hast, steh ich gerne zu Verfügung :)

  4. Pingback: Symfony2: Einfaches Eventhandling – ebene7

Schlagwörter: Adapter, Amazon, Animation, Annotations, Anonyme Klasse, Ant, Apache, API, Array, ArrayAccess, Attachment, AutoLoader, Bedienung, Bedingung, Benchmark, Bildbearbeitung, BOM, Bootstrap, Bot, Byte Order Mark, Callback, CamelCase, Canvas, Captcha, Cheatsheet, CLI, Closure, Cloud, CodeSniffer, Community, Comparator, Contest, Controller, Converter, CouchDB, Countable, Cronjob, CSV, CustomLibrary, Custom_Model, Data Mapper, Datei, Datenbank, Datenstruktur, Datentypen, Dating, Decorator, Dekorierer, Design Patterns, Dump, Duplikat, each, Eclipse, Entwicklung, Entwurfsmuster, Enum, Erweiterung, Eventhandling, Exception-Handling, Extension, Factory, Fehler, Flash, Foreach, Formatierung, Formular, Funktion, Futon, Header, HTML5, HTTP, IDE, If, Implementierung, InnoDB, Interceptor, Interface, isset, Iterator, Java, JavaScript, jQuery, Konfiguration, Konsole, Kontrollstruktur, kopieren, Late Static Binding, Layout, Linux, Listeners, Logging, Löschen, Magento, Magic Methods, Marketing, Methode, Model, MVC, MySQL, NetBeans, Objekt, Observable, Observer, OOP, Operator, Parameter, Partnersuche, Performance, PHP, phpMyAdmin, PHPUnit, Plugin, Proxy, Qualitätssicherung, Query, Reflection, Request, Response, Rest-API, Rockstar, Routing, S3, Samba, Scheifen, Schleife, Schutz, Secure Shell, Selbstreferenz, Shop, Sicherheit, Sicherung, Singleton Pattern, SOAP, Sortierung, Sourcecode, Spam, Speicherproblem, Spickzettel, SPL, SSH, Statement, Stellvertreter, Strategy Pattern, Stream, String, Sun VirtualBox, Support, Switch, Symfony, Symfony2, Symfony Live, Tag, Template, Template Method, Ternär Operator, Testing, Thumbnail, Tool, Tour, Twig, Type-Cast, Umwandlung, Underscore, unset, Vererbung, Verzweigung, Video, Videospiel, Virtualisierung, Visitor Pattern, Vorschaubild, walk, Webserver, Webservice, Weiterleitung, Wrapper, Youtube, Zeitsteuerung, Zend Framework, Zend_Cloud, Zend_CodeGenerator, Zend_Http_Client, Zend_Service, Zugriffsmethode