Symfony: Mehr Performance mit Doctrine

Object-Relation-Mapper (ORM) sind nützliche Werkzeuge, wenn es darum geht, komplexe Sachverhalte in einer Programmiersprache abzubilden und zusammenzufassen. Sie nehmen uns besonders dort Arbeit ab, wo Listen aus aggregierten Informationen mehrerer Datenbank-Tabellen dargestellt werden. Hier also mein kleiner Guide zum mehr Performance mit Doctrine in Symfony.

Die am häufigsten verwendete ORM für Symfony ist Doctrine. Auch wenn es daher viele Hilfeseiten, Dokumentation und Tutorials in Verbindung mit Symfony gibt, kommt Doctrine nicht mit den besten Methoden out-of-the-Box in unser Projekt. Je nach Anzahl der Many-to-One, One-to-Many und Many-to-Many Beziehungen kann ein übergreifenden Ausgeben dieser Informationen schnell exponentiell ausschlagen, wie das folgende Beispiel zeigt.

Symfony: Mehr Performance mit Doctrine - Profiler

Für nur 21 abgerufene Elemente werden 151 Anfragen an die Datenbank gesendet. Dies geht mit 166 Millisekunden noch recht flott, aber es sind auch nur 21 Elemente…

Interessant wird es, wenn mehr als eintausend Elemente abgerufen werden. Sei es für eine Suche, Aggregation, Massenverarbeitung oder den Export von Daten: Jedes Projekt hat mindestens eine dieser Anforderungen. Wie geht man am besten vor?

Auf Standard-Funktionen möglichst verzichten…

Doctrine kommt out-of-the-Box mit Funktionen wie find() – für eine einzelne Entität -, findBy() – für alle Entitäten, die auf ein gegebenes Suchmuster passen – oder findAll() – um einfach alle Entitäten abzurufen. Diese Funktionen sind natürlich sehr zeitsparend bei der Entwicklung und auch für das Debugging nützlich, kämpfen aber mit dem Prinzip des Lazy-Loading.

Das Lazy-Loading ist eine Methode, bei der nur „vorrätige Informationen“ aus der Tabelle der Entität entnommen werden. Alle Referenzen zu anderen Tabelle werden schlicht ignoriert und bleiben leer; führt man z.B. einen dump() auf eine lazy-loaded Entität aus, fehlen definitiv sämtliche Referenzen, was für das Debuggen nicht nützlich ist. Werden während der weiteren Verarbeitung (z.B. im Twig-Template) zusätzliche Informationen auf referenzierten Tabelle benötigt, wird für jede einzelne Information eine neue Abfrage ausgeführt. So entstehen z.B. 151 Abfrage für nur 21 Items…

…stattdessen auf Repositories setzen

Symfony biete es an, zusätzlich zu einer Entität ein Repository zu erstellen, das automatisch via Annotation verknüpft wird. Nutzt man etwa das Kommando php bin/console doctrine:generate:entity oder nutzt das ähnliche Feature des Maker-Bundle (für Projekte ab v4.x), wird sofort eine Entität inklusive eines Repository für eure eigenen Statements erzeugt.

Die Repositories zu nutzen hat gleich mehrere Vorteile:

  1. Statt ein findBy() an mehreren Stellen mit denselben Parametern zu nutzen, sind Funktionen im Repository einfacher nachnutzbar.
  2. Ab Symfony v4.x: Ein Repository ist ein Service, der auch in jeden anderen Service injiziert werden kann.
  3. Euer Code ist aufgeräumter, da es eine separate Klasse für alle Abfrage zu einer Entität gibt.
  4. Ein Repository kann mit zusätzlichen (Hilfs-)Funktionen aufgerüstet werden, die die Logik nochmal vereinfachen und übersichtlicher machen.
  5. Es folgt dem Prinzip aller Model-View-Controller Applikationen, da der Controller nur noch den Aufruf der Funktion enthält und nicht mehr die gesamte Logik der Abfrage.

Der Aufruf einer Abfrage über das Repository kann dann problemlos über den EntityManager gemacht werden.

Aufbau eines „Default-QueryBuilder“

Da es in objektorientierten Umgebungen unheimlich praktisch ist, Methoden und Services wiederzuverwenden, habe ich es auch für mich entdeckt, eine Methode als eigenen „Default-QueryBuilder“ für jede Entität zu erstellen. Dafür lege ich im zugehörigen Repository eine Methode ähnlich dieser an:

public function createDefaultQb(): QueryBuilder
{
    $qb = $this->createQueryBuilder('t')
        ->select('t, user, address')
        ->leftJoin('t.user', 'user')
        ->leftJoin('user.address', 'address')
    ;

    return $qb;
}

Dies bietet gleich mehrere Vorteile:

  1. Das select() zusammen mit dem join() holt bereits in einem Rutsch die Informationen aus der Datenbank, für die Doctrine sonst jeweils eine eigene Query schicken müsste.
  2. Jede meiner folgenden Methoden kann auf dieselbe Basis einer QB zurückgreifen und diese beliebig ergänzen.
  3. Auch der QB kann in folgenden Methoden mit weiteren addSelect() und join() Aufrufen erweitert werden.
  4. Der QB kann auch außerhalb des Repositories verwendet werden, z.B. in Formularen und Controllern:
    $myUser = $this->entityManager
        ->getRepository(MyEntity::class)
        ->createDefaultQb()
        ->andWhere('t.status = 1')
        ->andWhere('user.username = :username')
        ->setParameter('username', $user->getUsername())
    ;

Je nach Komplexität der Daten kann es zudem vorteilhaft werden, bestimmten Abstraktionsebenen durch „Flags“ hinzuzufügen. Das erlaubt eine konkrete Datenbasis für unterschiedliche Anwendungsfälle:

public function createDefaultQb(bool $withProfile = false): QueryBuilder
{
    $qb = $this->createQueryBuilder('t')
        ->select('t, user, options, roles')
        ->leftJoin('t.user', 'user')
        ->leftJoin('user.options', 'options')
        ->leftJoin('user.roles', 'roles')
    ;

    if ($withProfile) {
        $qb
            ->addSelect('lang, ref')
            ->leftJoin('t.languages', 'lang')
            ->leftJoin('t.references', 'ref')
        ;
    }

    return $qb;
}

public function listAll(bool $withProfile = false): array
{
    return $this->createDefaultQb($withProfile)
        ->getQuery()
        ->getResult()
    ;
}

Und die Controller werden plötzlich schmaler

Wusstet ihr, dass man die Repositories in Annotationen nutzen kann? Und auch die gewonnenen Objekte können gleich in der Security überprüft werden.

/**
 * Render a table of all registered users
 * @Route("", name="list_users")
 * @Entity("users", expr="repository.listAll(true)", class="App\Entity\User")
 * @Security("is_granted('allowed_to_view_users', users)")
 *
 * @param User[] $users
 *
 * @return Response
 */
public function show(array $users): Response
{
    return new Response($this->twig->render('list_users.html.twig', [
        'users' => $users,
    ]));
}

In diesem Beispiel bekommen wir ein Array von User direkt als Parameter in die Funktion. Bevor die Funktion überhaupt ausgeführt wird, geht selbiges Array in einen Voter, um dort überprüft zu werden. Schlägt die Überprüfung fehl, landet der Nutzer auf der Standard Error-403 (Zugriff verweigert) Seite.

Ein paar abschließende Worte

Doctrine lässt viel Spielraum für Experimente und Performance. Mit Sicherheit ist diese Vorgehensweise noch ausbaufähig. Auch dieser Beitrag ist lediglich ein Beispiel, um Wiederverwendbares zu sammeln und zu ordnen, Code und Zeit zu sparen. Ich habe diese Methode für mich entdeckt und fahre sehr gut damit.

Wie bändigt Ihr denn Doctrine in Symfony?

Kommentar hinterlassen

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