Développement de ce site : Étape 3 - Analyse des URLs et Multi-pages

17/09/2020

Pour ce troisième billet sur le sujet, j'aborde l'implémentation d'un composant essentiel dans une application Web : le "Router" ; il permet d'analyser l'URL entrante pour déterminer quelle est le code à appeler/executer/retourner.

Tous les Frameworks Web MVC sérieux possèdent un composant qui gère les routes, Plates PHP en tant que micro-framework n'en possède pas, c'est donc l'occasion d'en développer un sur mesure pour mon site.
Pour rappel, Plates est executé à partir d'un unique fichier qui sert de point d'entrée (index.php), en s'appuyant sur l'URL Rewriting, le Routeur, et plusieurs fichiers de vues, on va chercher dans ce billet à associer des vues différentes à des URLs pour avoir un site multi-pages.

Fonctionnement du Routeur

Input : une URL

La partie interessante de l'URL est la partie entre le nom de domaine et les arguments GET (après le ?) que je vais appeler arguments de routes.

Les arguments de routes sont donc précédés par des "/" et il est possible d'en avoir aucun (racine) ou plusieurs.

Output : une Vue/Page

Sans trop chercher à faire quelques choses de complexe, ce qui va principalement m'interesser est le premier argument mais dans le cas des billets que je n'évoquerai pas ici, il faut pouvoir également récupérer les autres parties.

Le routeur va principalement analyser le premier argument est à partir d'une liste d'expression va retourner le nom de la vue/page à éxecuter.
J'imagine déjà un ensemble de 4 routes pour le site.

Le nom utilisé par la vue peux être différente (cas du works & posts) que l'URL d'entrée, ainsi je décorelle la stratégie SEO (nom des routes) de la mécanique Plates (nom des fichiers de vue).

Redirection

Comme le Routeur se charge déjà d'analyser les URLs, on va le doter de la capacité de déterminer si l'URL appelante doit déboucher sur une redirection.
Le mécanisme de redirection (code de retour HTTP 301) est incontournable si l'on veux suivre la règle Une URL <=> Une Page et éviter ainsi le bête et méchant duplicate content.

De plus, je souhaite garder une relation entre les pages de mon ancien site et celles du nouveau qui justement sont accèssibles par des URLs différentes.
On va utiliser ce mécanisme pour éviter des erreurs 404 et faire comprendre aux moteurs de recherche que la page a changé d'endroit.

J'imagine déja un ensemble de redirections pour le site

URL Rewritting et Plates

Certes ma configuration Apache/NGINX me permet de faire en sorte que / ou "" (vide) appel index.php, il faut aussi ajouté un bout de code pour que toutes les URLs qui ne soient pas des fichiers (images, css, js, assets,...) pointent aussi vers index.php.

Fichier .htaccess pour apache
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^ index.php [L]
</IfModule>

Le Code du Routeur

Initialisation

Fichier includes/Router.php
<?php
namespace PersoWebSite; 

class Router {
    protected $rootDir;
    protected $requestURI;
    protected $queryString;
    protected $computedRoute;

    public function __construct($file) {
        $script = $_SERVER['SCRIPT_NAME'];
        $filePath = explode('/', $file);
        $fileScript = $filePath[count($filePath) - 1];
        $this->rootDir = str_replace($fileScript, "", $script);
        $this->queryString = $_SERVER['QUERY_STRING'];
        $this->requestURI = substr($_SERVER['REQUEST_URI'], strlen($this->rootDir));
    
        if($this->requestURI === false) {
            $this->requestURI = "";
        }
        $this->requestURI = str_replace("?".$this->queryString, "", $this->requestURI);

        $this->redirect();

        $this->computedRoute = $this->computeRoute();

        if($this->isNotFound()) {
            header( "HTTP/1.1 404 Not Found" );
        }
    }

    public function getRootDir() {
        return $this->rootDir;
    }

    public function getRequestURI() {
        return $this->requestURI;
    }

    public function getQueryString() {
        return $this->queryString;
    }

    public function getComputeRoute() {
        return $this->computedRoute;
    }

    public function isNotFound() {
        return ($this->computedRoute === self::NOTFOUND);
    }

    // Retourne dans un tableau les différents éléments de l'URI
    public function getRequestURIParts() {
        return explode('/', $this->getRequestURI());
    }

    // Retourne dans une chaine de caractère le premier élément de l'URI 
    public function getRequestURIFirstPart() {
        $endParts = $this->getRequestURIParts();
        return array_shift($endParts);
    }

    // Retourne dans un taleau les différents éléments de l'URI imputé du premier élément 
    public function getRequestURIEndsParts() {
        $endParts = $this->getRequestURIParts();
        array_shift($endParts);
        return $endParts;
    }

    // Retourne vrai si la requête possède d'autre élément de requêtes
    public function hasRequestURIEndsParts() {
        return !empty($this->getRequestURIEndsParts());
    }
}
?>

Le constructeur s'utilise en lui passant la "super-variable" __FILE__ comme argument depuis le index.php, cela permet de se baser à partir du nom réél du fichier et permettre ainsi une exploitation dans un sous dossier.

Ensuite, il va forger quatres objets :

En plus des classiques "getters", on ajoute quatres méthodes qui permettent de manipuler via des tableaux les éléments de routes :

Calcul des Routes

Fichier includes/Router.php
<?php
    const NOTFOUND = "notfound";

    protected $routes = [
        '' => 'accueil',
        'services' => 'services',
        'realisations' => 'works',
        'billets' => 'posts'
    ];

    // Retourne sous forme de chaine de caractère la route à utiliser
    // notfound en cas de route non reconnu
    protected function computeRoute() {
        $firstPart = $this->getRequestURIFirstPart();
        if(!array_key_exists($firstPart, $this->routes)) {
            return self::NOTFOUND;
        }

        return $this->routes[$firstPart];
    }
?>

La fonction computeRoute récupère le premier argument de route et fait correspondre à partir du tableau $routes défini dans la classe la vue correspondante. Dans le cas d'une route non trouvée la fonction retourne la contante NOTFOUND.

Calcul des Redirections

Fichier includes/Router.php
<?php
    protected $redirectRules = [
        'index.php' => '',
        // anciennes URL du site 
        'accueil' => '',
        'index.php?action=services' => 'services',
        'index.php?action=projets' => 'realisations',
        'projets' => 'realisations',
    ];

    protected $autorizedSubRoutes = [
        'billets'
    ];

    // Retourne la route de redirection à utiliser si la requête est reconnu par une règle de redirection
    // Retourne false si aucune règle n'a été levée
    protected function shouldRedirect() {
        $toChecks = [];
        if(!empty($this->queryString)) {
            // on test d'abord l'URL complète avec les paramètres GET s'il y en a
            $toChecks[] =  $this->requestURI."?".$this->queryString;
        }
        // Ensuite on test l'URL sans les paramètres
        $toChecks[] = $this->requestURI;

        
        foreach($toChecks as $uriToCheck) {
            if(array_key_exists($uriToCheck, $this->redirectRules)) {
                return $this->rootDir.$this->redirectRules[$uriToCheck];
            }
        }

        $uriToCheck = $this->getRequestURIFirstPart();
        // Puis si la ressource demandé termine par un ou plusieurs slash (/) et qu'il existe une route pour la ressource sans le(s) slash(s) alors on redirige vers la route normal
        $lastCharacters = substr($this->requestURI, -1);
        $matches = [];
        preg_match("~\/+$~", $this->requestURI, $matches);
        if(!empty($matches)) {
            if(array_key_exists($uriToCheck, $this->routes)) {
                return $this->rootDir.$uriToCheck;
            }
        }

        // Enfin, on test de manière globale si la route est utilisé à avoir des subroutes
        if($this->hasRequestURIEndsParts() && !in_array($uriToCheck, $this->autorizedSubRoutes)) {
            return $this->rootDir.$uriToCheck;
        }
        return false;
    }


    // Redirection vers redirectTo. Si redirectTo est égale à false alors la redirection est calculé à partir de shouldRedirect()
    public function redirect($redirectTo = false) {
        if($redirectTo === false) {
            $redirectTo = $this->shouldRedirect();
        }

        if($redirectTo !== false) {
            header("Status: 301 Moved Permanently", false, 301);
            header("Location: {$redirectTo}");
            header("Cache-Control: no-store");
            exit();
        }
    }
?>

La fonction shouldRedirect est un peu plus complexe et determine selon plusieurs critères si une redirection doit être réalisée :

A noter que je n'ai pas besoin d'indiquer le premier / dans la définition des règles de redirection car il est automatiquement ajouté par $this->rootDir.

La fonction redirectTo est directement utilisée dans le constructeur pour courcuiter la logique de rendu et rediriger le client avec les bons en-têtes HTTP (code 301). Elle est également utilisable en lui passant une URL spécifique depuis l'exterieur car sa porté est public.

Intégration du Routeur

J'ai fait le choix pour alléger au maximum le fichier index.php de coder le plus de chose dans la classe Router sans toutefois perdre en souplesse. En effet, l'intégration du Routeur est très facile et se fait rééllement en trois lignes de code.

Fichier index.php
<?php
    // 1ere ligne de code, on déclare la classe
    use PersoWebSite\Router;

    // 2eme ligne de code, on déclare un objet Router
    $router = new Router(__FILE__);
       
    // ...
    // initialisation de Plates
    // ...

    // 3eme ligne de code,  on récupère la route calculé qui correspond à la vue plates.
    $view = $router->getComputeRoute();

    // ...
    // passage de variable à vue
    // ...

    // On modifie juste la vue à rendre
    echo $template->render("view::{$view}");
?>

Le simple fait d'instancier l'objet, réalise automatiquement la mécanique d'analyse d'URL, de redirection et de récupération de la vue à afficher par la méthode render().

Pour rappel, les fichiers de vues doivent être positionnés dans le dossier templates/views, et s'ils n'existent pas, un jolie message d'erreur Plates (LogicException) fait planter le rendu de la page.
Comme défini dans mes routes, je dois alors avoir 5 templates :

Customisation du Helper pour faire des liens

Dans le billet précédent consacré à cette série, j'avais abordé l'utilisation d'un Helper pour réaliser différentes opérations depuis les vues et notamment pour inclure les fichiers CSS et Javascript. Désormais, on va s'appuyer sur le Router qui à préalablement calculé $rootDir pour fournir un helper qui fabrique une URL.

Fichier includes/Helper.php
<?php

namespace PersoWebSite;
class Helper {
    protected $baseURL;
    protected $router;

    public function  __construct($router) {
        $this->router = $router;
        $this->baseURL = $_SERVER["REQUEST_SCHEME"]."://".$_SERVER['SERVER_NAME'];
    }

    public function cssURL($cssFile) {
        return $this->baseURL.$this->router->getRootDir()."assets/css/".$filename;
    }

    public function imgURL($filename) {
        return $this->baseURL.$this->router->getRootDir()."assets/img/".$filename;
    }

    public function linkURL($uri) {
        return $this->baseURL.$this->router->getRootDir().$uri;
    }
    
}
?>

Ensuite par exemple pour avoir une URL (href) propre vers la page services, il suffit d'appeler la fonction $helper->linkURL('services') dans la vue.
En terme d'évolution, on pourrait imaginer laisser le calcul du baseURL au Router ou encore s'amuser à vérifier si l'URI passée en paramètre nécessite une redirection et ainsi retourner la redirection.

Conclusion

On a pu voir comment développer une mécanique de routing plutôt simple pour associer une route à une page avec un minimum de code à intégrer. De plus, on a vu comment implémenter une politique forte en matière de redirection pour améliorer ou du moins ne pas dégrader le référencement.

Toutefois, je n'ai pas encore abordé la mécanique permettant de gérer les différentes pages pour les billets à savoir ce que j'ai appelé les sous-routes ; cela fera l'objet d'un prochain article spécifique.

Enfin, je tiens à souligner que l'écriture de cette article m'a permis de lever certains problèmes notamment sur les redirections que j'avais mis en place sur mon site en production.

Retour aux billets Billet plus ancien : Prestashop 1.5 & PHP 7 Billet plus récent : Conception d'un document imprimable avec des outils Web