Eigene Annotationen in Symfony – Teil 1: Controller und Klassen-Eigenschaften

LifeStyle Team

In Symfony Projekten sind sie allgegenwärtig, ein oft und gern genutztes Werkzeug: Annotationen. Fast schon selbstverständlich setzt man sie, um bei Doctrine Entities die Datenbankeigenschaften zu setzen, den Serializer zu steuern oder vieles mehr.
Da kommt man schnell auf die Idee eigene Annotationen nutzen zu wollen, um Vorgänge zu automatisieren oder zusätzliche Konfigurationen oder Werte in seinen Klassen unterzubringen auf die man später reagieren oder damit arbeiten kann.
Glücklicherweise ist es mit Symfony recht einfach eigene, solche Annotationen erstellen und nutzen zu können. Zwei erste, grundlegende Fälle dies zu tun sollen hier vorgestellt werden:

Eigene Annotationen an Eigenschaften von Klassen
für z.B. zusätzliche Konfigurationen

Eigene Annotationen an Controller-Actions
für z.B. das Steuern weiterer Mechanismen

Begleitend zu diesem Artikel steht auch ein kleines Beispielprojekt bereit mit dem diese Anwendungsfälle direkt ausprobiert und nachvollzogen werden können (siehe Verlinkung am Ende des Artikels).

Eigene Annotationen an Eigenschaften von Klassen##

Nehmen wir als erstes Beispiel eine einfache Datenklasse deren Eigenschaften wir weitere Werte geben wollen um diese später im Code zu verwerten oder darauf zu reagieren:

class DemoModel
{
    /**
     * @MyCustomAnnotation("SampleAnnotation", demo="yes")
     *
     * @var string
     */
    private $demoValue;
   ...
}

Die Eigenschaft „demoValue“ hat unsere zusätzlich Annotation „MyCustomAnnotation“ mit einem einfachen Wert und einem zusätzlichen, benanntem – wie man es von z.B. Serializer oder Doctrine Annotationen kennt.

Zu aller erst müssen wir eine weitere Datenklasse anlegen, die unsere Annotation beschreibt und selber Eigenschaften enthält für alle Parameter die unserer Annotation evtl. übergeben werden können, bzw. die wir unterstützen wollen.
Minimalbeispiel einer Annotationsklasse mit einem custom Parameter:

namespace AppBundle\Annotation;
use Doctrine\Common\Annotations\Annotation;
/**
 * @Annotation
 * @Target({"PROPERTY"})
 */
class DemoAnnotation extends Annotation
{
    /**
     * @var string
     */
    public $demo; 
}

Unsere „DemoAnnotiation“ Klasse erbt von der Doctrine „Annotation“ Klasse, was uns einige Arbeit abnimmt.

U.a. hat diese Annotation zwei Parameter die ihr übergeben werden können – einmal den Wert der ohne weitere Namenszuweisung übergeben wird, z.B.:

@MyCustomAnnotation("SampleAnnotation")

der in der Eigenschaft $value abgelegt, die von der Mutterklasse vererbt wurde, und einmal den Wert „demo“ der als weiterer benannter Parameter übergeben werden kann, wie im Beispel der Datenklasse bereits gezeigt:

@MyCustomAnnotation("SampleAnnotation", demo="yes")

Weiterhin zu beachten, und für die Funktion notwendig, sind die Annotationen an unserer Annotationsklasse selbst:

@Annotation
@Target({"PROPERTY"})

Diese sind nötig damit sich Doctrine entsprechend um unsere Klasse kümmern kann, weiss worum es geht (nämlich die „Properties“ der Klasse, in diesem Fall) und der AnnotationReader uns später die gewünschten Daten herauslesen kann.

Um diese Annotation nun auslesen zu können benötigen wir einen sogenannten „Hydrator“, eine Funktion die unsere Annotationen aus einer gegebenen Klasse ausliest und ein Datenobjekt erstellt in dem die übergebenen Parameter enthalten sind.

Am einfachsten legen wir uns hierzu einen Symfony Service an, der dann von jeder Stelle im Code nutzbar ist und diese Aufgabe erledigt.

Für dieses Beispiel liegt unser Annotaion Hydrator Service hier:

src/AppBundle/Service/AnnotationHydrator.php

Wie gesagt nimmt Symfony uns hier eine Menge Arbeit ab, um genauer zu sein Doctrine. Um and die Daten unserer Annotation zu gelangen nutzen wir den „Doctrine Annotation Reader“. Diesen binden wir – zusammen mit unserer eigenen Annotaionsklasse - über das passende „use“ Statement ein:

use Doctrine\Common\Annotations\AnnotationReader;
use AppBundle\Annotation\DemoAnnotation;

und erstellen dann in unserer Hydrator Funktion ein Readerobjekt damit:

// get the Doctrine annotation reader
$reader = new AnnotationReader();

(Alternativ kann man sich den AnnotationReader auch per Dependancy Injection in seinen Service hereinreichen lassen, um dieses Beispiel einfacher zu halten instanziieren wir ihn an Stelle einfach direkt – nicht ganz Best Practice, für dieses Beispiel aber ausreichend)

Weiterhin benötigen wir eine Reflection Klasse um zur Laufzeit unsere Datenklasse auslesen zu können und ggf. auch auf private Eigenschaften zuzugreifen:

// set up a reflection object of the actual data object
$reflectionObject = new \ReflectionObject(new $entityClass);

„$entityClass“ ist hierbei ein instanziiertes Objekt unserer Datenklasse.

Alles was wir nun noch zu tun haben ist durch alle Eigenschaften des Datenobjektes zu gehen und jeweils die Annotation auszulesen und die erhaltenen Daten entsprechend weiter zu verwerten. Hierzu verwenden wir eine Funktion des Reflection Objektes mit dem wir alle Eigenschaften des Datenobjektes erhalten (auch private) und wenden auf jedes einzelne dann den Doctrine Annotation Reader an dem wir die aus dem Reflection Objekt entnommene Eigenschaft sowie unsere Annotationklasse übergeben:

// loop through all properties of the data object
foreach($reflectionObject->getProperties() as $reflectionProperty)
{
    // use the Doctrine annotation reader to get our annotation class,
    // with all "annotated values" set for the current property of the
    // data class
    $annotationData = $reader->getPropertyAnnotation(
        $reflectionProperty,
        DemoAnnotation::class 
    );

    // also get the name of current property, so we know to what
    // property the annotation data belongs to
    $propertyName = $reflectionProperty->getName();

    // and while we're here, why not get the actual value of the data
    // class' property as well
    $reflectionProperty->setAccessible(true);
    $propertyValue = $reflectionProperty->getValue($entityClass);

An dieser Stelle haben wir jetzt alle Daten bereits zusammen, in „$annotationData“ habe wir ein Objekt unserer vorher angelegten Annotaions Klasse, gefüllt mit allen Werten die in der Annotation der Daten Klasse angegeben sind und in „$propertyName“ sowie „$properyValue“ haben wir auch den Namen respektive den Wert der aktuell verarbeiteten Eigenschaft.

Der Aufruf von

$reflectionProperty->setAccessible(true);

Ist nötig um auch auf ggf. als „privat“ markierte Eigenschaften zugreifen zu können, ohne diesen Aufruf würde beim Zugriff auf diese eine Exception wegen unerlaubtem Zugriffs geworfen.
Schließen wir nun unsere Schleife ab in dem wir die nun bekannten Daten zusammenfassen und zurückgeben können – der Einfachheit dieses Beispieles zuliebe schreiben wir das Ganze einfach in ein Array:

// finally save everything into our return array
    $propData = [];
    $propData['name'] = $propertyName;
    $propData['value'] = $propertyValue;
    $propData['annotationData'] = $annotationData;
    $resultSet[$propertyName] = $propData;
}

// and return it
return $resultSet;

Hier stehen der aufrufenden Funktion nun alle Informationen zu allen Eigenschaften der Datenklasse zur Verfügung um diese weiterzuverarbeiten oder darauf zu reagieren.

Eigene Annotation an Controllern##

Auch für eigene Annotationen die man an Controllern (bzw. genauer an Controller-Actions) nutzen will beginnt alles mit einer eignen Annotationsklasse die beschreibt welche Parameter ggf. übergeben werden können und in der diese dann abgelegt werden:

use Doctrine\Common\Annotations\Annotation;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ConfigurationAnnotation;

/**
 * @Annotation
 */
class DemoControllerAnnotation extends ConfigurationAnnotation
{
    /**
     * @var string
     */
    public $value;

    /**
     * @var string
     */
    public $demo;

    /**
     * @return string
     */
    public function getDemo()
    {
        return $this->demo;
    }

    /**
     * @param string $demo
     * @return DemoControllerAnnotation
     */
    public function setDemo($demo)
    {
        $this->demo = $demo;
        return $this;
    }

    /**
     * @return string
     * @see ConfigurationInterface
     */
    public function getAliasName()
    {
        return 'demoAnnotation';
    }

    /**
     * @return bool
     * @see ConfigurationInterface
     */
    public function allowArray()
    {
        return false;
    }
}

Der Aufbau ist fast identisch mit unserer Eigenschaften-Annotation nur dass die @Target Annotation fehlt und eine andere Basisklasse vererbt wird.

Auch hier gibt es wieder einen unbenannten Standardparameter der in $value abgelegt wird sowie benannte Parameter die in Eigenschaften entsprechend des Parameternamens gesetzt werden. Ausswerdem müssen die passenden Getter und Setter für alle benutzten Parameter/Eigenschaften existieren, da Symfony diese nutzt um die Werte in unserem Annotationsobjekt zu setzen. Zu guter Letzt gibt es noch zwei besondere Methoden (getAliasName() und allowArray()) die vorhanden sein müssen und intern von Symfony aufgerufen werden, wobei getAliasName() einen Kurznamen für diese Annotation zurückgibt und allowArray() angibt ob von dieser Annotation nur eine pro Controller-Action („false“) oder mehrere („true“) erlaubt sind.

Das Hinzufügen der Annotation ist wenig überraschend:

/**
 * @DemoAnnotation("A Demo", demo="obviously")
 */
public function demoAction()
{
    // ...
}

Da wir die in dieser Annotation enthaltenen Daten z.B. entweder in den Controller reichen, oder darauf reagieren und ggf. ganz andere Dinge tun wollen, ist der Zugriff und die Verarbeitung hier ein klein wenig mehr Aufwand, aber immer noch recht übersichtlich.

Allerdings benötigen wir hierzu einen Event Subscriber der die Verarbeitung unserer Annotation übernimmt.

Der generelle Aufbau unserer Klasse für den Subscriber schaut wie folgt aus:

namespace AppBundle\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;

class ControllerAnnotaionSubscriber implements EventSubscriberInterface
{
    /**
     * @return array
     */
    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::CONTROLLER_ARGUMENTS => ['annotationProcessor']
        ];
    }

    /**
     * Event called for processing our controller annotations
     *
     * @param FilterControllerEvent $event
     */
    public function annotationProcessor(FilterControllerEvent $event)
    {
        /* ... */
    }
}

Wer schon einmal mit Event Subscribern in Symfony gearbeitet hat wird dies sofort erkennen, es handelt sich hierbei um einen simplen Subscriber der auf den Event “Controller Arguments” vom Symfony Kernel gebunden wird.

An dieser Stelle kommt wieder ein wenig „Symfony Magie“ ins Spiel, denn um an unsere Annotation und die ggf. dort angegebenen Parameter zu gelangen hat Symfony uns eine Instanz unserer Annotationsklasse nützlicher Weise in die Attribute des Requests gelegt, und zwar unter dem Namen den wir in unserer Annotationsklasse mit getAliasName() zurückgeben mit vorangestelltem Unterstrich.

Das heißt um nun an die Daten unserer Annotation zu gelangen können wir diese einfach mit folgendem Aufruf aus dem Request holen (Das Requestobjekt wiederum ist im Eventobjekt hinterlegt):

// get annotation object
$demoAnnotation = $event->getRequest()->attributes->get('_demoAnnotation ')

An dieser Stelle haben wir nun unser Annotationsobjekt mit allen Eigenschaften entsprechend der am Controller angegebenen Werte gefüllt und können diese Informationen nach Belieben weiterverarbeiten und/oder darauf reagieren.

Dieser Event-Subscriber wird allerdings immer und für alle Controller aufgerufen, unabhängig davon ob unsere Annotation angegeben ist oder nicht, daher müssen wir vor der Verarbeitung einer evtl. angegebenen Annotation prüfen ob diese überhaupt angegeben wurde! Hierzu prüfen wir idealerweise zwei Dinge, zum einen ob im Request überhaupt das Attribut mit unserem Annotationsobjekt existiert und dann ob es sich auch um unsere Klasse handelt. Ein nutzbares Gerüst unserer Verarbeitungsmethode sähe dann z.B. so aus:

/**
 * Event called for processing our controller annotations
 *
 * @param FilterControllerEvent $event
 */
public function annotationProcessor(FilterControllerEvent $event)
{
    $annotation = $event->getRequest()->attributes->get('_demoAnnotation', null);
    if ($annotation instanceof DemoAnnotationClass) {
        /* ... */
    }
}

Zu guter Letzt müssen wir unseren Subscriber natürlich noch als Service definieren und Symfony dabei wissen lassen diesen bei der Eventverarbeitung entsprechend zu berücksichtigen:

services:
    my_annotations.subscriber:
        class: AppBundle\EventSubscriber\ControllerAnnotaionSubscriber
        tags:
            - { name: kernel.event_subscriber }

Damit haben wir nun eigene Annotationen die wir nach Belieben auswerten, weiterverarbeiten oder darauf reagieren können, an Eigenschaften von Klassen sowie an Controllern (bzw. deren Actions, um genau zu sein).

Es gibt natürlich noch weitere Möglichkeiten (eigene) Annotation einzusetzen, z.B. direkt an Klassen oder an deren Methoden – ähnlich den Controller Actions, jedoch an beliebigen Klassen.

Wie man solche Annotationen aufsetzt und anspricht wird ein einem separatem Artikel diskutiert werden.

Zu diesem Artikel gibt es ein kleines Demoprojekt: Download Demoprojekt
Das Projekt ist nach entpacken und „composer install“ voll funktionsfähig und demonstriert ausführlich alle in diesem Artikel besprochenen Vorgehensweisen.