Teaser - PHP-Framework: Routing Teil 2

PHP-Framework: Routing Teil 2

Willkommen zurück! Ihr lest den zweiten Teil über Routing. Solltet ihr direkt auf diesem Beitrag gelandet sein, würde ich empfehlen, vorher die anderen Beiträge dieser Serie zu lesen, da alle Beiträge mehr oder weniger aufeinander aufbauen. In diesem Fall solltet ihr auf jeden Fall vorher den ersten Teil zu Routing lesen.

Nachdem ihr im letzten Beitrag die Grundlagen gesehen habt, geht es heute daran, die definierten Routen mit der aufgerufenen URL abzugleichen und die zugehörige Methode des Controllers aufzurufen.

Platzhalter in Routen

Bevor wir aber ins Matching von Routen einsteigen, möchte ich vorab noch eine weitere Funktion vorstellen, die in den Router integriert ist. Ihr könnt in den Routen nämlich auch Platzhalter verwenden. Die benötigt ihr immer dann, wenn ihr dynamische Teile in euren URLs benötigt, z.B. um auf die Details von Beiträgen zu verlinken. In einem solchen Fall hat man in der Regel folgende URLs:

https://some-url.de/posts/1

Am Ende der URL seht ihr die ID des Beitrags, die natürlich vom jeweiligen Beitrag abhängig ist. Eine solche Route könnt ihr folgendermaßen definieren:

/**
 * @Route=/posts/:id
 *
 * @param Response $response
 *
 * @return Response
 */
public function detail($id, Response $response): Response {
    // some logic here
}

Ein Platzhalter in den Routen wird immer durch einen : eingeleitet. Für die Namensgebung des Platzhalters gelten die selben Regeln wie für Variablen in PHP. Wie ihr seht, könnt ihr den Namen des Platzhalters innerhalb der Route direkt als Name des Parameters eurer Methode angeben. Während des Dispatchings, wird der Wert aus der Route in diesem Parameter übergeben. Ohne weitere Definition des Platzhalters, solltet ihr auf Type Hinting des Parameters in der Methode vorerst verzichten.
Die Reihenfolge der Methodenparameter spielt hier übrigens keine Rolle. Und wie ihr am Beispiel der $response Variable seht, könnt ihr die Platzhalter natürlich auch mit weiteren Parametern kombinieren, die per Dependeny Injection geliefert werden. Auch hier sei wieder auf einen späteren Beitrag dieser Serie verwiesen, in dem das Thema DI näher erläutert wird.

Ihr könnt so viele Platzhalter definieren, wie ihr wollt.

Stellen wir uns als nächstes vor, wir haben einen dynamischen Teil einer Route, und wir wissen, dieser Teil ist immer eine Ganzzahl. In diesem Fall wäre es doch praktisch, wenn wir das der Route direkt mitteilen könnten. Und ihr habt es euch wahrscheinlich schon gedacht, wenn ich diese Einleitung mache, dass das auch in meinem Framework möglich ist. Dazu schreibt ihr einfach einen beliebigen regulären Ausruck in <>. Hier das Beispiel von vorhin, um eine solche Definition erweitert:

/**
 * @Route=/posts/:id<\d+>
 *
 * @param Response $response
 *
 * @return Response
 */
public function detail(int $id, Response $response): Response {
    // some logic here
}

Dieser einfache reguläre Ausdruck sorgt dafür, dass die Route nur dann als Treffer erkannt wird, wenn es sich um mindestens eine Ziffer handelt. D.h. eine Route wie /posts/john wird nicht erkannt, während /posts/1234 entsprechend aufgerufen wird. Jetzt könnt ihr auch den Parameter der Methode mit einem Type Hint versehen, da sichergestellt ist, dass hier immer eine Ganzzahl übergeben wird.

Das Ganze wird dann interessant, wenn ihr z.B. die folgenden beiden Routen habt:

https://some-url.de/posts/1
https://some-url.de/posts/create

In diesem Fall, soll natürlich die oben definierte Route nicht aufgerufen werden, wenn der String create gefunden wird. In diesem Fall wollt ihr höchstwahrscheinlich eher in einem Formular landen, mit dem ihr neue Beiträge anlegen könnt.

Stellen wir uns für die nächste Funktion eines Platzhalters folgende Routen vor:

https://some-url.de/posts
https://some-url.de/posts/1

Was, wenn ihr nun diese beiden Routen innerhalb einer Methode behandelt wollt? Auch für diesen Fall, gibt es in der Syntax für die Definition eines Platzhalter einen definierten Wert. Hier direkt das Beispiel dazu:

/**
 * @Route=/posts/:id<\d+>?
 *
 * @param Response $response
 *
 * @return Response
 */
public function detail(Response $response, int $id): Response {
    // some logic here
}

Ihr hängt einfach ein ? an das Ende der Definition. Pro Route kann es nur einen optionalen Platzhalter geben und dieser muss am Ende der Route stehen. Beachtet auch, dass dieses Beispiel aktuell noch fehlschlagen würde, wenn ihr die Route /posts aufruft. Das liegt daran, dass als Wert für den $id Parameter der Methode ein int erwartet wird, jedoch in diesem Fall ein Leerstring übergeben wird.

Um dieses Problem zu lösen, gibt es zwei Wege. Ihr könnt zum einen, wie in PHP üblich, einfach einen Standardwert für euren Methodenparameter angeben:

/**
 * @Route=/posts/:id<\d+>?
 *
 * @param Response $response
 *
 * @return Response
 */
public function detail(Response $response, int $id = 0): Response {
    // some logic here
}

Beachtet, dass hierfür nun die Reihenfolge der Parameter getauscht werden muss, da auch PHP optionale Parameter nur am Ende der Methodendefinition erlaubt.

Bei der zweiten Möglichkeit, könnt ihr den Standardwert direkt in der Definition der Route angeben. Fügt den Standardwert dazu einfach hinter dem ? ein. Hier das gleiche Beispiel wie zuvor, dieses Mal jedoch über die Routendefinition:

/**
 * @Route=/posts/:id<\d+>?0
 *
 * @param Response $response
 *
 * @return Response
 */
public function detail(int $id, Response $response): Response {
    // some logic here
}

Die Angabe über die Route hat den Vorteil, dass ihr den Parameter in der Methode nun wieder an beliebiger Stelle angeben könnt, da der Dispatcher sicherstellt, dass hier ein entsprechender Wert übergeben wird.

Das ist auch das letzte Beispiel, das ich zu Routenplatzhaltern zeigen wollte. Darin seht ihr nun eine Definition mit allen möglichen Funktionen. Hier nochmal eine Zusammenfassung:

  • Platzhalter in Routen beginnen immer mit einem Doppelpunkt
  • Namensgebung folgt den selben Regeln wie Variablen in PHP
  • Einschränkung möglicher Werte über reguläre Ausdrücke
  • Nur ein optionaler Parameter erlaubt, dieser muss am Ende stehen
  • Standardwerte entweder per Methodenparameter oder über Routendefinition

Matching

Als nächstes möchte ich euch ein paar Einblicke in das Thema Matching geben. Die komplette Funktion, zum Finden einer definierten Route, ist insgesamt 70 Zeilen lang. Ich werde daher versuchen, die wichtigen Bestandteile, in kleinen Häppchen zu erklären.

Die aufgerufene URL wird zunächst anhand des / in ihre Einzelteile zerlegt. Im Anschluss daran, werden alle definierten Routen durchlaufen, und jeder Teil mit der aktuellen Route abgeglichen:

$method = $this->request->getMethod();
$currentRoute = ltrim($this->request->getUrl(), '/');
$definedRoutes = $router->getRoutes($method);
$currentRouteParts = explode('/', $currentRoute);

foreach ($definedRoutes as $definedRoute => $handlerMethod) {
    $definedRouteParts = explode('/', ltrim($definedRoute, '/'));
    $isLastPartOptional = false !== mb_strpos($definedRouteParts[count($definedRouteParts) - 1], '?');

    if (!$isLastPartOptional && count($definedRouteParts) !== count($currentRouteParts)) {
        continue;
    } else if (
        $isLastPartOptional
        &&  (
            count($currentRouteParts) < count($definedRouteParts) - 1
            || count($currentRouteParts) > count($definedRouteParts)
        )
    ) {
        continue;
    }

    foreach ($definedRouteParts as $index => $definedRoutePart) {
        // other logic
    }
}

Ihr seht in diesem Codeausschnitt auch drei Stellen, die für gesteigerte Geschwindigkeit beim Matching sorgen sollen:

  • Es werden nur Routen untersucht, die mit der HTTP Methode des aktuellen Aufrufs übereinstimmen
  • Routen, die keinen optionalen Platzhalter am Ende haben, werden ignoriert, wenn die Anzahl der Segmente nicht mit der aufgerufenen Route übereinstimmen
  • Routen, die einen optionalen Platzhalter haben, werden ignoriert, wenn die Anzahl der Segmente nicht übereinstimmt, wobei hier Routen gültig sind, die ein Segment länger sind, als die aktuell aufgerufene Route

Innerhalb der zweiten Schleife, wird zunächst überprüft, ob es sich beim aktuell zu untersuchenden Routenabschnitt um einen statischen Teil handelt. Ist dies der Fall, aber es wird keine Übereinstimmung gefunden, wird die Route direkt ignoriert:

if (':' !== mb_substr($definedRoutePart, 0, 1)) { // static part

    if ($currentRoutePart !== $definedRoutePart) { // if static part not matches, no hit
        continue 2;
    }
}

Wir benötigen an dieser Stelle ein continue 2, da die äußere Schleife die definierten Routen durchläuft, und andernfalls nur das aktuell untersuchte Segment ignoriert werden würde.

Der nächste Codeausschnitt untersucht die Bestandteile eines Platzhalters:

$placeholderName = mb_substr($definedRoutePart, 1);
$regex = $defaultValue = '';

if (false !== mb_strpos($placeholderName, '<')) { // dynamic part has to match regex
    list($placeholderName,) = explode('<', $placeholderName);

    if (preg_match('/<(.*)>/', $definedRoutePart, $regexMatch)) {
        $regex = $regexMatch[1];
    }
} else if (false !== mb_strpos($placeholderName, '?')) { // dynamic part is optional
    list($placeholderName,) = explode('?', $placeholderName);
}

if (false !== mb_strpos($definedRoutePart, '?')) { // check if dynamic part has default value
    $defaultValue = explode('?', $definedRoutePart)[1] ? : '';
}

// if dynamic part not matches regex, no hit (only if not empty, can only occur if last part is optional)
if ($regex && !preg_match('/' . $regex . '/', $currentRoutePart) && '' !== $currentRoutePart) {
    continue 2;
}

Zuerst wird überprüft, ob es einen regulären Ausdruck gibt. Wenn es sich um einen optionalen Platzhalter handelt, wird als nächstes überprüft, ob dieser einen Standardwert aufweist. Sollte das Routensegment nicht dem regulären Ausdruck entsprechen, oder ein optionaler Platzhalter hat keinen Wert bzw. Standardwert, wird die aktuelle Route wiederum mit continue 2 ausgelassen.

Sollte eine Route alle Prüfungen überstehen, wird nichts mehr durchlaufen. Der erste Treffer wird verwendet und es werden alle ermittelten Werte, die für den Dispatcher wichtig sind, gespeichert:

$this->routeParams = $routeParameters;
$this->defaultValues = $defaultValues;
$this->matchedHandler = $handlerMethod;
$this->matchedRoute = $definedRoute;

Die Werte der Platzhalter und die Standardwerte werden getrennt gespeichert. Außerdem wird die Methode gespeichert, die der Route entspricht. Zuletzt wird auch die gefundene Route noch gespeichert.

Dispatching

Das letzte Puzzleteil ist das Dispatching. Damit ist der Schritt gemeint, der die gefundene Methode nun aufruft, und das Ergebnis zurückliefert. Ich möchte an dieser Stelle nur sehr oberflächlich auf diese Logik eingehen, da ein Großteil davon Dependency Injection darstellt, und ich dieses Thema in einem der nächsten Beiträge ausführlicher vorstellen möchte.

Hier der eigentliche Aufruf der Handlermethode und das Senden der Antwort an den Browser:

list($fullQualifiedClassName, $handlerMethodName) = explode('::', $matcher->getMatchedHandler());
$handler = ObjectFactory::createInstance($fullQualifiedClassName);
$arguments = [];

// some other logic to get arguments

try {
    $response = call_user_func_array([$handler, $handlerMethodName], $arguments);
} catch (\Exception $e) {
    /** @var Response $response */
    $response = ObjectFactory::createInstance(Response::class);
    $response
        ->setCode(ResponseCodes::HTTP_INTERNAL_SERVER_ERROR)
        ->setBody($e->getMessage());
}

if ($response instanceof Response) {
    $response->send();
} else {
    /** @var Response $responseObject */
    $responseObject = ObjectFactory::createInstance(Response::class);
    $responseObject
        ->setBody((string) $response)
        ->send();
}

Die Variablen $handler und $handlerMethodName werden dabei direkt aus den Informationen erstellt bzw. ausgelesen, die der Matcher zur Verfügung stellt. Das Array mit den Argumenten, wird im Vorfeld über Analyse der aufzurufenden Methode mittels Reflection und Dependency Injection gefüllt.

Sollte bei dem Aufruf etwas schief laufen, wird ein entsprechender Fehler an den Browser geschickt. Wenn der Rückgabewert der aufgerufenen Methode keine Instanz eines Responses ist, wird dieser Wert als Body an ein dafür erstelltes Response Objekt übergeben, das daraufhin an den Browser gesendet wird.

Zusammenfassung

In diesem und dem vorhergehenden Beitrag habe ich viele Einblicke in das Routing über Annotationen gegeben. Ich hoffe, meine Ausführungen waren verständlich, und ihr habt nun ein besseres Gespür dafür, wie das Ganze funktioniert. Ihr habt auch gesehen, dass ein Framework sehr viel Arbeit im Hintergrund erledigen muss, damit ihr eure Routen so einfach wie möglich definieren könnt.

Nun will ich euch aber nicht länger aufhalten! Wir lesen uns beim nächsten Mal.