Teaser - PHP-Framework: Routing Teil 1

PHP-Framework: Routing Teil 1

Willkommen zurück! Im heutigen Beitrag werden wir mit dem Thema Routing beginnen. Da es sich hier um ein eher komplexes Thema handelt, das aus vielen einzelnen Komponenten besteht, habe ich mich dafür entschieden, zwei Beiträge zu erstellen. Du liest aktuell den ersten, in dem es um die grundlegenden Funktionen geht. Dazu gehören das Durchsuchen der Verzeichnisse nach Controllern und die Ermittlung definierter Routen über Reflection.

Solltest du direkt auf diesem Beitrag gelandet sein, empfehle ich zuerst die vorhergehenden Beiträge dieser Serie zu lesen. Alle Beiträge bauen aufeinander auf.

Verzeichnisse durchsuchen

Historisch gesehen, bietet PHP mehrere Möglichkeiten, um ein Verzeichnis nach bestimmten Dateien zu durchsuchen. Die folgenden Abschnitte stellen zwei der älteren und einen modernen Ansatz vor, den ich auch im Framework verwendet habe.

Historisch: opendir, scandir, is_dir

Die erste historische Methode benutzt die Verzeichnisfunktionen von PHP. Mit dieser Methode, müsst ihr ein Verzeichnis mit opendir öffnen, mit scanndir durchlaufen und mit is_dir überprüfen, ob es sich um ein Verzeichnis oder eine Datei handelt. Wenn ihr ein Verzeichnis gefunden habt, müsst ihr die gleichen Schritte solange wiederholen, bis alle Dateien aus allen Unterverzeichnissen gefunden wurden.

Obwohl diese Methode natürlich funktioniert, muss doch sehr Vieles manuell erledigt werden. Ich müsst z.B. sicherstellen, dass die Verzeichnisse . und .. nicht gescannt werden, sonst könnt ihr schnell in einer Endlosschleife landen. Ein weiterer Faktor, der für mich entscheidend war, ist die Tatsache, dass der komplette Code im PHP 4 Stil, also prozedural, entwickelt werden müsste. Auch wenn die Logik in eigene Klassen verpackt wird, ist es doch der alte Stil.

Falls euch die nötige Logik interessiert, findet ihr eine ausprogrammierte Funktion auf php.net. Je nach Anwendungsfall kann es nötig sein, die Funktion an eure Bedürfnisse anzupassen.

Historisch: glob

Auch die glob Funktion eignet sich dazu, Dateien innerhalb einer Verzeichnisstruktur zu suchen. Laut Dokumentation, kann es hier Probleme geben, wenn ihr wirklich viele Dateien durchsuchen wollt (> 100 000). Eine weitere Einschränkung ist, dass es eher umständlich ist, damit auch Unterverzeichnisse zu durchsuchen.

Eine mögliche Lösung für das Problem der Unterverzeichnisse findet ihr z.B. auf Stack Overflow. Das Problem mit zu vielen Dateien lässt sich nur lösen, indem ihr PHP mehr Speicher über die php.ini gebt, da glob alle gefundenen Dateien in einem großen Array speichert.

Modern: Iteratoren

Ihr seht also, die beiden zuvor vorgestellten Methoden haben verschiedene Fallstricke. Kommen wir deshalb zur modernen Methode, die seit PHP 5 direkt in die Sprache integriert ist. Dabei handelt es sich um Iteratoren. Diese durchsuchen Verzeichnisse, wenn gewünscht auch rekursiv (also inklusive Unterverzeichnisse), und können bei Bedarf reguläre Ausdrücke auf die gefundenen Dateien anwenden. Im Gegensatz zu glob, werden die Treffer einzeln zurückgegeben, wodurch sich hier kein Speicherproblem ergibt. All das werde ich euch in kürze in einem Beispiel genauer zeigen.

Bei Anwendungen, die auf Basis des hier vorgestellten Frameworks entwickelt werden, gibt es in der Anwendungskonfiguration config/app.php einen Wert, der den Pfad zu bestimmten Klassen enthält. Für das hier beschriebene Routing, sind die Controller interessant. Der Pfad dafür ist im Wert controllerDirectory zu finden.

Da das Durchsuchen von Verzeichnissen und Finden von bestimmten Klassen eine häufige Aufgabe innerhalb des Frameworks darstellt, wurde die Logik in eine Hilfsklasse ausgelagert. Die Klasse heißt DirectoryInspection und erledigt zwei Dinge. Die erste Funktion, durchsucht ein übergebenes Verzeichnis rekursiv nach PHP Dateien. Die zweite Funktion inspiziert diese Dateien und gibt als Ergebnis die voll qualifizierten Klassennamen zurück. Voll qualifiziert bedeutet, dass auch der Namespace enthalten ist. Das ist wichtig, um daraus, unter Zuhilfenahme von Autoloading, direkt eine Instanz erstellen oder wie später beschrieben, Reflection verwenden zu können.

Verzeichnis durchsuchen

Werfen wir also zunächst einen Blick auf die Funktion, die alle PHP Dateien sucht:

/**
 * Return file name and path to all php files in given directory
 *
 * @param string $directory
 *
 * @return array
 */
public static function getFiles(string $directory): array {
    $directoryIterator = new \RecursiveDirectoryIterator($directory);
    $iterator = new \RecursiveIteratorIterator($directoryIterator);
    $fileIterator = new \RegexIterator($iterator, '/.+\.php$/i', \RecursiveRegexIterator::MATCH);
    $files = [];

    /** @var \SplFileInfo $file */
    foreach ($fileIterator as $file) {
        $files[] = $file->getPathname();
    }

    return $files;
}

Ich finde, der Code ist sehr übersichtlich, wenn ihr bedenkt, was hier alles passiert. Werfen wir also einen Blick auf die einzelnen Iteratoren und was deren Aufgabe ist.
Da hätten wir als erstes den RecursiveDirectoryIterator. Dieser ist einzig und alleine dafür zuständig, rekursiv Verzeichnisse zu lesen.
Als nächstes haben wir den RecursiveIteratorIterator. Er dient dazu, die einzelnen Verzeichnis Iteratoren, die uns der RecursiveDirectoryIterator liefert, zu durchlaufen.
Als letztes packen wir die Iteratoren für die Dateien, die wir vom vorherigen Aufruf erhalten, noch in den RegexIterator. Dieser wendet dann einen regulären Ausdruck auf alle gefundenen Dateien an. In unserem Fall, sollen alle Dateien mit der Endung .php gesucht werden. Der Parameter RecursiveRegexIterator::MATCH sorgt dafür, dass lediglich der Filter angewendet wird, sonst aber keine weitere Aktion ausgeführt wird. Wer genauere Informationen zu den verschiedenen Modi dieses Iterators haben will, kann sich dazu die Dokumentation auf php.net anschauen.

Das Ergebnis all dieser Aufrufe ist ein Iterator, den wir einfach mit foreach durchlaufen können, und der die Dateien in einer SplFileInfo Klasse verpackt ausspuckt. Daraus können wir den vollen Pfad zur Datei ermitteln, den wir im weiteren Verlauf benötigen.

Ich muss gestehen, für mich ist diese Verschachtelung auch jedes Mal eine neue Herausforderung. Aber die Einarbeitung in diese Klassen lohnt sich, wenn ihr häufig mit der Aufgabe der Verzeichnis- bzw. Dateisuche zu tun habt.

Klassen ermitteln

Wir haben nun also eine Liste mit Pfaden zu Dateien, die mögliche Controller Klassen und damit Routen als Annotationen beinhalten können. Um damit was anfangen zu können, benötigen wir als nächstes die Namen der Klassen, am besten mit Namespaces, was auch als voll qualifizierter Klassenname bezeichnet wird.

Nach kurzer Suche, was es hier für Möglichkeiten gibt, bin ich auf die Funktion token_get_all() gestoßen. Diese Funktion erwartet den PHP Quellcode als String und gibt ein Array zurück, in dem alle PHP Tokens dieses Codes zu finden sind. Um zu verdeutlichen, was diese Funktion zurückgibt, sehen wir uns ein kleines Beispiel aus der Dokumentation an:

$tokens = token_get_all('<?php echo; ?>');

foreach ($tokens as $token) {

    if (is_array($token)) {
        echo "Line {$token[2]}: ", token_name($token[0]), " ('{$token[1]}')", PHP_EOL;
    }
}

Das erste was auffällt ist, dass es wohl innerhalb des Arrays noch Unterarrays geben kann. Diese enthalten dann weitere Informationen zum Token:

  • Tokentyp: Eine Konstante, deren Namen wir über die Funktion token_name erhalten
  • Tokenwert: Hier finden wir z.B. den Klassennamen oder einen Teil des Namespaces
  • Zeilennummer: Normalerweise zu vernachlässigen

Soweit ich gesehen habe, ist ; der einzige Wert, der nicht als Array geliefert wird. Trotzdem ist es ein sehr wichtiger Wert, um z.B. bestimmen zu können, wann ein Namespace vollständig ist.

Hier die Ausgabe des obigen Codes:

Line 1: T_OPEN_TAG ('<?php ')
Line 1: T_ECHO ('echo')
Line 1: T_WHITESPACE (' ')
Line 1: T_CLOSE_TAG ('?>')

Ihr könnt sicher bereits erkennen, dass dieses Array nicht ganz so einfach zu verarbeiten ist. Dazu kommt noch die Tatsache, dass die einzelnen Bestandteile eines Namespaces als einzelne Tokens enthalten sind.

Die komplette Funktion, die ich zur Analyse dieses Arrays verwende, um Namespace und Klassenname zu ermitteln, umfasst ca. 55 Zeilen. Da die Funktion etwas zu lang ist, um hier den kompletten Quellcode zu zeigen, möchte ich kurz die darin befindliche Logik beschreiben.

Ermittlung des Namespaces

Um den Namespace zu erhalten, suche ich zunächst nach dem Token mit dem Wert namespace. Ab diesem Zeitpunkt wird alles, was nicht einem Leerzeichen entspricht, mittels \ zusammengefügt, bis ich das Token mit dem Wert ; finde. Danach ist der Namespace komplett.

Ermittlung des Klassennamens

Die Ermittlung des Klassennamens ist nicht ganz so aufwändig. Hier warte ich auf das Token class und nehme den nächsten Wert, der kein Leerzeichen ist.

Voll qualifizierter Klassenname

Der einfachste Teil ist dann, die beiden vorhergehenden Bestandteile, mit einem \ dazwischen, zusammenzufügen.

Einführung Annotationen

Ich habe ja bereits mehrfach erwähnt, dass ich das Routing in meinem Framework per Annotationen machen möchte. Das ist eine Definition innerhalb des PHPDoc Kommentarblocks einer Klasse oder einer Methode dieser Klasse.

Für das Routing in meinem Framework gibt es aktuell drei mögliche Definitionen:

  • @RoutePrefix - Definiert ein Prefix für alle Routen eines Controllers und wird deshalb direkt für die Klasse definiert.
  • @Route - Definiert eine einzelne Route und wird auf einer Methode definiert.
  • @Method - Definiert die HTTP Methode, mit der eine Route aufgerufen werden kann (Standard: GET). Mögliche Methoden befinden sich im Enum RequestMethods des Http Namespaces.

Hier ein einfaches Beispiel für einen User Controller:

/**
 * @RoutePrefix=/user
 *
 * @package NextDirection\Application\Controller
 */
class User {

    /**
     * @Route=/
     * @Method=GET
     *
     * @param Response $response
     *
     * @return Response
     */
    public function index(Response $response): Response {        
        return $response;
    }
}

Obwohl die GET Methode als Standard definiert ist und somit auch weggelassen werden kann, wollte ich doch alle möglichen Definitionen in diesem Beispiel zeigen. Beschäftigen wir uns nun als nächstes damit, wie wir diese Information aus diesen Definitionen extrahieren können, um Routing zu ermöglichen.

Reflektieren gefundener Klassen

Nachdem wir nun alle Klassen innerhalb des Controller Verzeichnisses identifiziert haben, können wir sie genauer untersuchen. Dazu verwenden wir die in PHP integrierte ReflectionClass Klasse. Diese erwartet im Konstruktor als einzigen Parameter den voll qualifizierten Namen der Klasse, die untersucht werden soll:

$reflectionClass = new \ReflectionClass($className);

Über die Instanz der Klasse können wir nun Informationen abfragen, wie die Methoden oder den PHPDoc Kommentar. Leider werden die Bestandteile des Kommentars nicht über die integrierten Funktionen zerlegt. Hier müssen wir also selbst Hand anlegen.

Prefix prüfen

Um das Prefix aller nachfolgenden Routen zu ermitteln, verwende ich folgende Logik:

$prefixRegEx = '/@RoutePrefix=(.*)\s/m';
$prefix = preg_match($prefixRegEx, $reflectionClass->getDocComment(), $prefixMatch) ? $prefixMatch[1] : '';

Die Funktion preg_match gibt praktischerweise einen Boolean mit den Werten wahr oder falsch zurück. Deshalb kann innerhalb einer Zeile direkt das gefundene Prefix oder ein Leerstring ermittelt werden. Der verwendete reguläre Ausdruck sucht dabei alles, was nach dem = kommt und kein sog. Whitespace Zeichen ist. Das sind z.B. Leerzeichen, Zeilenumbrüche oder Tabulatoren. Wer selbst oft mit regulären Ausdrücken zu tun hat, dem kann ich nur folgende Seite empfehlen. Damit könnt ihr sehr einfach eure Ausdrücke zusammenstellen und auch gleich prüfen, was sie finden.

Route und Methode bestimmen

Hier will ich nicht zu viele Worte verlieren, sondern direkt den Beispielcode zeigen, den ich danach etwas genauer erklären werde:

$routeRegEx = '/@Route=(.*)\s/m';
$methodRegEx = '/@Method=(.*)\s/m';

foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
    $docComment = $reflectionMethod->getDocComment();
    $method = preg_match($methodRegEx, $docComment, $methodMatch) ? $methodMatch[1] : 'GET';
    preg_match($routeRegEx, $docComment, $routeMatch);

    $route = $routeMatch ? $routeMatch[1] : null;

    if (null !== $route) {

        if (!isset($this->routes[$method])) {
            $this->routes[$method] = [];
        }

        $fullRoute = str_replace('//', '/', $prefix . $route);
        $fullRoute = mb_strlen($fullRoute) > 1 ? rtrim($fullRoute, '/') : $fullRoute;

        if ($this->checkRouteValidity($fullRoute)) {
            $this->routes[$method][$fullRoute] = $className . '::' . $reflectionMethod->getName();
        }
    }
}

Innerhalb einer Schleife werden alle öffentlichen Methoden der zu untersuchenden Klasse durchlaufen. Im Wesentlichen wird für die Ermittlung von Route und Methode ein ähnliches Vorgehen wie beim Prefix angewendet. Diesmal wird der PHPDoc Kommentar von den Methoden untersucht.

Die gefundenen Routen werden je nach HTTP Methode gruppiert, um das Matching, welches im nächsten Beitrag beschrieben wird, zu beschleunigen. Nach Normalisierung der gefundenen Route (Entfernung überflüssiger / und entfernen des abschließenden /), wird sie noch auf Gültigkeit überprüft und dann in einem Array gespeichert.

Wann genau eine Route gültig ist, wird auch im nächsten Beitrag genauer beleuchtet.

Zusammenfassung

Ich habe euch in diesem Beitrag eine Menge an Routing Grundlagen gezeigt. Nachdem wir zuerst ein bestimmtes Verzeichnis inklusive Unterverzeichnissen nach PHP Dateien durchsucht haben, wurden diese Dateien analysiert, um die voll qualifizierten Klassennamen zu erhalten. Im Anschluss daran, wurden die Klassen genauer untersucht und definierte Routen extrahiert.

Im nächsten Beitrag werden wir uns ansehen, wie die Verarbeitung einer aufgerufenen Route abläuft, und wie wir dann in der entsprechenden Methode des Controllers landen.

Nun will ich euch aber nicht länger aufhalten! Ich hoffe ihr haltet die Spannung bis zum nächsten Beitrag aus.