Architecture des sites

Jean-Marc Lecarpentier
GREYC — Université de Caen

Introduction

Architecture MVCR

La base de départ de ce tutoriel est l'architecture MVCR présentée par Alexandre Niveau dans le cadre de l'enseignement Technologies Internet de L3. Pour rappel :

Nous allons partir cette architecture de base du site des poèmes pour bâtir notre application de démo. Rappelons son architecture :

.
├── images
│   ├── baudelaire.jpg
│   ├── nerval.jpg
│   ├── rimbaud.jpg
│   └── verlaine.jpg
├── index.php
├── skin
│   └── poems.css
└── src
    ├── Router.php
    ├── control
    │   └── Controller.php
    ├── model
    │   ├── Poem.php
    │   ├── PoemStorage.php
    │   ├── PoemStorageStub.php
    │   └── texts
    │       ├── chanson_d_automne.frg.html
    │       ├── correspondances.frg.html
    │       ├── dans_les_bois.frg.html
    │       └── ma_boheme.frg.html
    └── view
        ├── View.php
        └── template.php
    

Observations

En partant de cette architecture, la création d'un site complet a mis en évidence les inconvénients suivants :

  • le Router deviant de plus en plus gros au fur et à mesure de l'ajout de pages et de fonctionnalités dans le site
  • il n'y a qu'un seul contrôleur pour la gestion des poèmes. Cela signifie que la gestion de plusieurs type d'objets va être dans le même contrôleur, ce qui risque d'être très vite le bazar.

Nous allons donc revoir cette architecture, en conservant le même principe, mais en essayant de découpler la gestion des requêtes HTTP (requêtes et réponses) et le fonctionnement de l'application suivant un modèle donné.

Principes de base

Nous allons découpler un certain nombres de points :

  • découpler routeur et exécution en créant un FrontController qui gère l'exécution du système (programme générique donc) et laisse le soin au routeur d'interpréter la requête de l'utilisateur (classe Router dépendant donc de l'application).
  • créer un contrôleur pour les poèmes. Cela permettra d'avoir dans le modèle de l'application plusieurs types d'objets et de regrouper l'exécution de diverses actions dans une même classe. Ce contrôleur se charge aussi de « remplir » la vue.
  • encapsuler la gestion de la requête HTTP et de sa réponse dans deux classes dédiées Request et Response.

Request et Response

Ces 2 classes ont pour objectif d'encapsuler la requête HTTP et de préparer la réponse. La classe Request permet de gérer les paramètres GET, POST reçus, d'interpréter les en-têtes HTTP, etc. La classe Response permet de spécifier les en-têtes HTTP a envoyer au client et d'envoyer le réponse lorsque toute exécution est terminée. On pourra donc commencer avec un embryon comme suit :

<?php

/**
 * embryon de classe Request.
 *
 * La classe ne gère et n'encapsule ici que les données GET, POST
 *
 * La classe ne gère pas non plus le cas où un .htaccess est utilisé pour faire
 * la réécriture d'URL.
 */

class Request
{
    private $get;
    private $post;
    private $files;
    private $server;

    public function __construct($get, $post, $files, $server)
    {
        $this->get = $get;
        $this->post = $post;
        $this->files = $files;
        $this->server = $server;
    }
    
    /**
    * détection des requêtes AJAX
    */
    public function isAjaxRequest()
    {
    	return (!empty($this->server['HTTP_X_REQUESTED_WITH']) && strtolower($this->server['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest');
    }

    /**
     * @param $key la clé à chercher dans GET
     * @param $default la valeur à renvoyer si $key n'existe pas
     * @return null
     */
    public function getGetParam($key, $default = null)
    {
        if (!isset($this->get[$key])) {
            return $default;
        }
        return $this->get[$key];
    }

    /**
     * @param $key la clé à chercher dans POST
     * @param $default la valeur à renvoyer si $key n'existe pas
     * @return null
     */
    public function getPostParam($key, $default)
    {
        if (!isset($this->post[$key])) {
            return $default;
        }
        return $this->post[$key];
    }

    /**
     * obtenir tous les paramètres POST
     * @return array
     */
    public function getAllPostParams()
    {
        return $this->post;
    }
}


<?php

/**
 * Class Response
 *
 * embryon de classe pour gérer la réponse HTTP
 */
class Response
{
    /**
     * @var array
     * liste des en-têtes HTTP
     */
	private $headers = array();

    /**
     * @param $headerValue
     * ajouter un en-tête à la liste
     * par exemple pour changer le Content-Type
     */
	public function addHeader($headerValue) {
		$this->headers[] = $headerValue;
	}

    /**
     * envoie tous les headers au client
     * @todo utilise la liste dans l'ordre où les en-têtes ont été ajoutés ce qui peut devenir incohérent
     */
	public function sendHeaders() {
		foreach ($this->headers as $header) {
			header($header);
		}
	}

    /**
     * @param $content
     * envoi de la réponse au client
     */
	public function send($content)
	{
		$this->sendHeaders();
		echo $content;
	}  
}


Ces deux classes sont complètement indépendantes de l'application. On pourra donc les réutiliser dans tout projet.

Contrôleur de classe et Vue

Un contrôleur de classe est un contrôleur qui ne s'occupe que de gérer les actions de la classe de modèle à laquelle il est lié. Dans notre exmple simple il n'y aura qu'un seul contrôleur PoemController, mais on peut imaginer avoir plusieurs contrôleurs, par exemple PoemController pour gérer les actions publiques (lister et voir les poèmes) et AdminController pour les action de gestion des poèmes.

Le contrôleur de classe a besoin de recevoir la requête (objet Request) pour pouvoir accéder aux divers paramètres dont il aura besoin (par exemple les données POST pour gérer un formulaire), l'objet Response pour les éventuels en-têtes HTTP à ajouter/modifier, et de la vue pour lui donner les éléments qui seront affichés.

Gestion de la vue

Dans la version de base des poèmes, la vue est dépendante du modèle des poèmes, mais dans une application plus complète, celle-ci doit être là aussi indépendante des classes utilisées. Nous ne pourrons pour commencer qu'obtenir une indépendance très relative. À un niveau général, la vue prend un squelette de page HTML et « remplit » les zones du squelette (variables $title, $content et $menu). En général, ce sera le contrôleur de classe qui génèrera le contenu HTML de ces zones. On pourrait donc commencer avec un contrôleur de classe qui renvoie une liste de chaines HTML correspondant aux zones. Cette solution se trouve vite très peu pratique car on peut imaginer que tous les contrôleurs de classe n'ont pas le même nombre de zones à remplir, etc. Le contrôleur de classe va donc donner à la vue les items ou zones qu'il souhainte remplir et la vue se chargera ensuite de créer la page web. La première version présentée ici a encore besoin de nombreuses améliorations mais elle permet de démarre avec une application simple.

<?php

class View 
{
    /**
     * @var array $parts le tableau des parties de HTML qui pourront être utilisés
     */
    protected $parts;
    /**
     * @var string $template le nom du fichier servant de squelette HTML à la page
     */
    protected $template;

    public function __construct($template, $parts = array())
    {
    	$this->template = $template;
        $this->parts = $parts;
    }

    public function setPart($key, $content)
    {
        $this->parts[$key] = $content;
    }

    public function getPart($key)
    {
        if (isset($this->parts[$key])) {
            return $this->parts[$key];
        } else {
            return null;
        }
    }

    /**
     * @return string
     * génère la vue (i.e. la page web) avec les contenus en remplissant les zones définies
     */
    public function render()
    {
        $le_titre = $this->getPart('title');
        $le_contenu = $this->getPart('content');
        $le_menu = $this->getPart('menu');

        ob_start();
        include($this->template);
        $data = ob_get_contents();
        ob_end_clean();

        return $data;
    }
}

PoemController

Le contrôleur de classe se charge d'exécuter ce qui est nécessaire et de remplir la vue. Pour l'utilisation de ce contrôleur par le FrontController présenté ensuite, une méthode execute() sert à lancer une action demandée.

<?php

require_once("application/PoemStorage.php");
require_once("application/PoemStorageStub.php");

class PoemController
{
    protected $request;
    protected $response;
    protected $view;

    public function __construct(Request $request, Response $response, View $view)
    {
        $this->request = $request;
        $this->response = $response;
        $this->view = $view;
        
        // create menu 
        $menu = array(
			"Accueil" => '',
			"Poème sympa" => '?o=poem&amp;a=show&amp;id=01',
			"Autre poème" => '?o=poem&amp;a=show&amp;id=02',
			"Un poème moins connu" => '?o=poem&amp;a=show&amp;id=03',
			"Un dernier" => '?o=poem&amp;a=show&amp;id=04',
		);
        $this->view->setPart('menu', $menu);
    }

    /**
     * exécuter le contrôleur de classe pour effectuer l'action $action
     *
     * @param $action
     */
    public function execute($action)
    {
        $this->$action();
    }

    public function defaultAction()
    {
        return  $this->makeHomePage();
    }

    public function show() {
        // tester les en-têtes HTTP avec Response
        $this->response->addHeader('X-Debugging: show me a poem');
        $id = $this->request->getGetParam('id');
        $poemStorage = new PoemStorageStub();
        $poem = $poemStorage->read($id);
        if ($poem !== null) {
            /* Le poème existe, on prépare la page */
            $image = "images/{$poem->getImage()}";
            $title = "« {$poem->getTitle()} », par {$poem->getAuthor()}";
            $content = "<figure>\n<img src=\"$image\" alt=\"{$poem->getAuthor()}\" />\n";
            $content .= "<figcaption>{$poem->getAuthor()}</figcaption>\n</figure>\n";
            $content .= "<div class=\"poem\">{$poem->getText()}</div>\n";

            $this->view->setPart('title', $title);
            $this->view->setPart('content', $content);

        } else {
            $this->unknownPoem();
        }
    }

    public function unknownPoem() {
        $title = "Poème inconnu ou non trouvé";
        $content = "Choisir un poème dans la liste.";
        $this->view->setPart('title', $title);
        $this->view->setPart('content', $content);
    }

    public function makeHomePage() {
        $title = "Bienvenue !";
        $content = "Un site sur des poèmes.";
        $this->view->setPart('title', $title);
        $this->view->setPart('content', $content);
    }
}


Une amélioration importante sera la génération des codes HTML avec des vues et templates afin d'avoir le moins de code HTML possible dans ce fichier. De plus cela permettra d'éditer le HTML beacoup plus facilement.

FrontController et Router

Il reste maintenant à lancer l'application et déterminer ce que le client demande.

La classe Router utilisée dans la démo de base des poèmes gère à la fois l'éxécution des programmes et le routage de la requête. ces deux tâches vont être découplées afin d'avoir une classe qui gère l'exécution (FrontController) et une autre classe qui gère le routage. On peut remarquer que le FrontController est complètement indépendant de l'application alors que le Router dépend de celle-ci.

FrontController

Le FrontController est instancié dans index.php. Afin de pouvoir être exécuté dans tous les cas, par exemple en ligne de commande (donc sans données HTTP), il doit recevoir des données : celles-ci proviendront d'un objet Request. Il aura aussi besoin d'un objet Response. L'index.php est donc simplement :

<?php
set_include_path("./src");

require_once("framework/FrontController.php");
require_once('framework/Request.php');
require_once('framework/Response.php');

/* Cette page est simplement le point d'arrivée de l'internaute
 * sur notre site. On se contente de lancer le FrontController.
 *
 */
 
$server = $_SERVER;

// simuler une requête AJAX
//$server['HTTP_X_REQUESTED_WITH'] = 'xmlhttprequest'; 

$request = new Request($_GET, $_POST, $_FILES, $server);
$response = new Response();
$router = new FrontController($request, $response);
$router->execute();


Lorsque le FrontController s'exécute, il va alors demander au Router de lui indiquer quel contrôleur de classe instancier et quelle action exécuter pour celui-ci. Cela permet donc de rendre le FrontController complètement indépendant de l'application.

Pour commencer, le FrontController peut être comme suit. Nous verrons plus loin une façon de générer la vue.

<?php

require_once('framework/Router.php');
require_once('framework/View.php');
require_once('application/PoemController.php');

class FrontController
{
    /**
     * request et response
     */
    protected $request;
    protected $response;

    /**
     * constructeur de la classe.
     */
    public function __construct($request, $response)
    {
        $this->request = $request;
        $this->response = $response;
    }

    /**
     * méthode pour lancer le contrôleur et exécuter l'action à faire
     */
    public function execute()
    {
    	$view = new View('application/templates/template.php');
   	
        // demander au Router la classe et l'action à exécuter
        $router = new Router($this->request);
        $className = $router->getControllerClassName();
        $action = $router->getControllerAction();

        // instancier le controleur de classe et exécuter l'action
        $controller = new $className($this->request, $this->response, $view);
        $controller->execute($action);
        
        if ($this->request->isAjaxRequest()) {
        	$content = $view->getPart('content');
        } else {
        	$content = $view->render();
        }
        
        $this->response->send($content);
    }
}


Router

Afin de pouvoir travailler, le FrontController doit demander au Router deux informations : la classe de contrôleur à instancier et l'action à exécuter. Le Router se charge de cela et pour répondre il a besoin de la requête reçue, donc de l'objet Request. Selon le schéma des URLs choisi, le Router travaillera un peu différemment. Pour commencer, nous avons choisi des requêtes où les paramètres sont passés en GET à l'application, avec deux paramètres o pour l'objet à utiliser et a pour l'action à exécuter, par exemple index.php?o=poem&a=list. Selon les cas, un ou des paramètre(s) complémentaires seront nécesaaires, par exemple pour afficher un poème : index.php?o=poem&a=show&id=02. Une première version du Router serait donc :

<?php

require_once('framework/Request.php');

class Router
{
    protected $controllerClassName;
    protected $controllerAction;
    protected $request;

    public function __construct(Request $request)
    {
        $this->request = $request;
        $this->parseRequest();
    }

    public function getControllerClassName()
    {
        return $this->controllerClassName;
    }

    public function getControllerAction()
    {
        return $this->controllerAction;
    }

    protected function parseRequest()
    {
      // un nom de package est-il spécifié dans l'URL ?
        $package = $this->request->getGetParam('o');

        // Regarder quel contrôleur instancier
        switch ($package) {
            case 'poem':
                $this->controllerClassName = 'PoemController';
                break;
            /** exemple pour plus tard
            case 'image':
                $this->controllerClassName = 'ImageController';
                break;
             */

            default:
                // idem ici, on peut imaginer un package à utiliser par défaut
                // j'utilise ArticleController pour l'instant car c'est le seul existant
                $this->controllerClassName = 'PoemController';
        }

        // tester si la classe à instancier existe bien. Si non lancer une Exception.
        if (!class_exists($this->controllerClassName)) {
            throw new Exception("Classe {$this->controllerClassName} non existante");
        }

        // regarder si une action est demandée dans l'URL
        // si le paramètre 'a' n'existe pas alors l'action sera 'defaultAction'
        $this->controllerAction = $this->request->getGetParam('a', 'defaultAction');
	}
}


Gestion des cas anormaux

Dans toute application, des cas d'exécution anormaux peuvent se présenter, sans pour autant devoir déclencher des erreurs d'exécution. Par exemple, lorsque l'on demande à la classe PoemStorageStub de lire l'article d'identifiant 14, le ssytème doit-il déclencher une erreur ou s'arrêter si le n°14 n'existe pas ? Non, il doit faire remonter l'information mais ne doit pas arrêter l'exécution.

Un moyen pour cela est l'utilisation des exceptions. Voire ce tutoriel complet sur OpenClassrooms.

On va pour l'instant se contenter d'utiliser la classe Exception de base. Vous pourrez par la suite améliorer en créant des exceptions spécifiques à tel ou tel cas.

Cas du contrôleur de classe

Le code du contrôleur de classe ne sait pas quoi faire si le contrôleur de classe ne contient pas l'action demandée. C'est typiquement un cas où lancer une exception serait pratique :

class PoemController
{
    public function execute($action)
    {
        if (method_exists($this, $action)) {
            return $this->$action();
        } else {
            // que faire si l'actio n'existe pas ??
            throw new Exception("Action {$action} non trouvée");
        }
    }

    // ...
}

Très bien, mais si l'on ne change rien d'autre, alors dans le cas où l'action n'existe pas on aura une erreur fatale qui dit que l'exception n'a pas été attrapée (Uncaught exception). Pas mieux donc...

Pour vraiment bien utiliser les exceptions, il faut utiliser les blocs try { ... } catch (Exception $e) { ...} au bon endroit. Le bon endroit dépend bien entendu des diverses possibilités d'utilisation des exceptions, mais pour éviter le message Uncaught exception il faut au moins un try/catch dans index.php, ou mieux dans le FrontController pour garder l'affichage de la page web complète.

    public function execute()
    {
    	$view = new View('application/templates/template.php');

        try {
            // demander au Router la classe et l'action à exécuter
            $router = new Router($this->request);
            $className = $router->getControllerClassName();
            $action = $router->getControllerAction();

            // instancier le controleur de classe et exécuter l'action
            $controller = new $className($this->request, $this->response, $view);
            $controller->execute($action);
        } catch (Exception $e) {
            $view->setPart('title', 'Erreur');
            $view->setPart('content', "Une erreur d'exécution s'est produite");
        }

        if ($this->request->isAjaxRequest()) {
            $content = $view->getPart('content');
        } else {
            $content = $view->render();
        }

        $this->response->send($content);
    }

Encore mieux, on peut décider d'afficher la trace d'exécution uniquement lorsque l'on développe le site et afficher un message générique lorsque le site est en production 

        try {
            // pas de changement
        } catch (Exception $e) {
            $view->setPart('title', 'Erreur');
            // on suppose que la config du site contient une constante MODE_DEV qui vaut true si on est en mode développement
            // utiliser la constante MODE_DEV déclarée en config pour décider du message à afficher
            if (MODE_DEV === true) {
                // alors afficher le détail de l'exception et sa trace d'exécution
                $content = $e->getMessage();
                $content .= "<div>" . nl2br($e->getTraceAsString()) . "</div>";
                $view->setPart('content', $content)
            } else {
                $view->setPart('content', "Une erreur d'exécution s'est produite.");
            }
        }

Cas du Router

Un autre exemple d'utilisation des exceptions dans notre cas serait pour le Router. En effet il pourrait être bon que le router vérifie si le contrôleur de classe associé à la requête existe bien. On pourrait donc ajouter dans la méthode parseRequest du Router ce petit test :

// tester si la classe à instancier existe bien. Si non lancer une Exception.
if (!class_exists($this->controllerClassName)) {
    throw new \Exception("Classe {$this->controllerClass} non existante");
}

Autres cas

On peut trouver d'autres cas, par exemple le cas de la méthode read() de PoemStorageStub qui pourrait lancer une exception si le poème demandé n'existe pas.

Conclusion

Notre application est désormais prête à monter en puissance et à gérer de plus en plus de choses. Un certain nombre de classes sont désormais découplées de la logique de l'application et peuvent être réutilisées. La démonstration ne montre aucune différence en apparence avec la démo. du système vu en L3. En apparence seulement... seule une indication est visible dans le navigateur qui nous indique que le code source PHP n'est pas le même.

Il reste maintenant à factoriser pas mal de choses. La plupart du code que l'on vient de faire peut être réutilisé tel quel. Il suffit pour cela de créer quelques interfaces et/ou classes abstraites. Le seul point non factorisable pour l'instant est la partie de décision faite dans le Router ainsi que les classes Poem* qui sont spécifiques à notre site.

L'utilisation de namespaces permettrait d'avoir des classes qui ont le même nom mais font partie de package différents. Enfin, le chargement automatique de classe nous évitera les require_once présents dans les fichiers PHP.

Prochaine étape donc : utilisation des namespaces, création d'interfaces, utilisation de classes dérivées pour éviter de réécrire toujours les mêmes morceaux de code.

Pour aller plus loin avec le routeur...

Le routeur mis en place ici est encore très basique. On pourrait bien sûr mettre ene place la réécriture d'URL pour avoir des pretty URLs. Mais surtout toute la logique du routage est contenue dans la méthode parseRequest. Ce serait bon de pouvoir l'en extraire et mettre cette logique dans un fichier de configuration par exemple, ce qui permettrait de sortir la classe Router de l'application pour la mettre dans le framework.

C'est ce que font la plupart des frameworks PHP, soit avec un fichier XML, YAML ou en utilisant des annotations dans les contrôleurs. Pour avoir une idée de comment ça marche :

  • Symfony routing
  • Laravel routing. Notons que Laravel est construit sur une base de Symfony, donc même si Laravel fournit un fonctionnement un peu différent de Symfony, les couches de bas niveau fonctionnent de la même façon
  • Yii routing. Yii fonctionne un peu différemment pour la configuration des routes, en particulier plutôt que d'utiliser un format XML ou YAML, les routes sont définies à l'aide de tableaux PHP.
  • Zend 3 routing fonctionne avec des tableaux PHP aussi.