darma | développement web freelance

Tips & Codes sources

«the First-click Suspense», part 2

Un Cache serveur HTML et PHP

Les caches Web

Un cache désigne en informatique un système permettant de stocker pendant un "certain de laps de temps" des données susceptibles d'être re-utilisées "plus tard", permettant ainsi une économie de calculs, l'optimisation des ressources machine, et un accès plus rapide à l'information lorsque celle-ci est stockée à un endroit plus proche de celui qui en fait la requête. Ces données devant toutefois refléter une certaine réalité quel que soit le moment où on les demande, la notion de durée et l'évaluation de la "fraîcheur" des données sont primordiales dans ce procédé : si un cache est trop persistent (données gardées en mémoire trop longtemps avant d'être rafraîchies) il nous montre des données périmées, inversement s'il est trop éphémère (s'il re-calcule trop souvent ses vues) il ne permettra pas d'économie réelle de ressources et sera inutile.
La performance d'un cache se situera donc dans sa capacité à évaluer les critères de nécessité de re-calcul.
Typiquement, un cache met en mémoire une vue (ensemble de données, comme par exemple une page web) la première fois qu'il la rencontre et note la date de cette rencontre. La prochaine fois que cette vue (identifiée de manière unique, par exemple par une URL) sera demandée au système, elle pourra soit être livrée telle que mise en mémoire dernièrement si la version cachée n'est pas "trop ancienne", soit re-générée, mémorisée et renvoyée dans le cas inverse. Généralement la vue peut elle-même signaler sa durée de vie, ou bien le cache peut être maître des dates d'expiration ou utiliser ses propres critères de "fraîcheur" (poids ou date de création de fichier, etc.).
Si l'on souhaite s'assurer d'obtenir, à partir d'un moment donné, uniquement des versions re-calculées de chaque vue, inutile de toutes les re-générer : il suffit donc de "vider le cache" (action très légère en ressources) et de laisser ce dernier se recomposer progressivement vue par vue demandée (étalement de l'utilisation des ressources).
En web (et pour les architectures PHP+MySQL), nous connaissons principalement 3 systèmes de cache :
  • le cache du navigateur qui copie localement sur la machine utilisateur certaines pages HTML et surtout leurs ressources (images, CSS, etc.) afin de les re-servir plus tard à l'utilisateur sans même en faire la requête au serveur.
  • le cache Apache situé au niveau du serveur, lire le guide de la mise en cache sur Serveur Apache.
  • le cache MySQL, qui garde en mémoire les résultats de requêtes SQL.
S'il est utile aux développeurs de connaître les balises meta HTML et les en-têtes HTTP relatifs à la mise en cache côté navigateur, cela ne permet d'optimiser les requêtes que pour un visiteur donné. Considérant que l'on ne doit pas mettre de pages PHP en cache Apache, qu'advient-il de notre site si son trafic augmente fortement et que sa homepage est demandée plusieurs dizaines de fois par seconde par autant de visiteurs différents, alors que malgré toutes les optimisations possibles son calcul prend plusieurs dixièmes de secondes? Cette question concerne aussi bien toutes les pages de notre site web, et la solution proposée ici s'applique particulièrement aux sites éditoriaux et généralement ceux pour lesquels, à un moment donné, les vues de chaque page ne diffèrent que très peu d'un visiteur à un autre. La valeur ajoutée de cette méthode sera d'autant plus forte que les pages de notre site sont gourmandes en requêtes et riches en contenu ; et même pour un site plus moyennement visité, l'intérêt, s'il n'est pas d'éviter un crash serveur, sera au moins de servir au visiteur des pages quasi-instantanées.

Un cache HTML, fonctionnement basique

On suppose ici que l'on a la possibilité d'inclure un script PHP en tête et en fin de toutes les pages du site afin de récupérer le buffer de sortie avant tout affichage (à l'aide des fonctions ob_start(), ob_get_contents() ... ).
On définit pour chaque page une durée d'expiration et un chemin du fichier de cache associé sur le serveur (en dehors de la racine web ou dans un dossier protégé par .htaccess). On prévoit également d'avoir un contrôle de suppression du cache page à page par le back-office (si l'on modifie par exemple un article, on supprime sa page cachée afin que les modifications apparaissent immédiatement sur le front sans avoir besoin d'attendre la durée normale d'expiration).
On met en forme ces propriétés et méthodes dans la classe PHP suivante :
<?php

class cache{

    private $expire; //la durée d'expiration, qui vaut par défaut une heure
    private $cache_path; //le chemin complet du fichier de cache sur le serveur		
	
    public function __construct($cache_path, $expire = 3600){
        $this->cache_path = $cache_path;
        $this->expire = $expire;		
    }

    //à placer en tête de page
    public function start(){
        //si le fichier de cache existe est n'a pas expiré, on le renvoie tel quel
        $expire = time() - $this->expire;		
        if(is_file($this->cache_path) && filemtime($this->cache_path) > $expire){
            include $this->cache_path;
            die();
        }
        //fichier de lock - pour empêcher plusieurs utilisateurs de générer la page 
        //en même temps (cas assez rare normalement, on se permet de faire 
        //attendre un peu, mais pas plus que le temps de génération de la page)
        $lock_path = $this->cache_path.'.lock';
        $loop = rand(1, 10);
        $count = 0;
        while(is_file($lock_path)){
            sleep(1);
            $count++;
            if($count >= $loop) break;
        }
        if(self::write($lock_path, 'lock') === false) 
            die('impossible d\'écrire le fichier : '.$lock_path);		
        //on prépare la génération de la page : on démarre le buffer de sortie
        ob_start();
    }

    //à placer en fin de page	
    public function end(){
        //récupère le contenu de la page évaluée
        $content = ob_get_contents();
        //vide et désactive le buffer de sortie
        while(@ob_end_clean());
        //enregistre le fichier de cache dernièrement calculé
        self::write($this->cache_path, $content);		
        //supprime le lock
        @unlink($this->cache_path.'.lock'); 			
        //évalue et affiche le contenu du cache
        include $this->cache_path;
    }

    //pour forcer la suppression du cache (back-office...)
    public function clear(){
        @unlink($this->cache_path); 	
    }
	
    static public function write($path, $content){
        if(!$handle = fopen($path, 'w')) return false;
        if(fwrite($handle, $content) === false) return false;
        fclose($handle);
        return true;		
    }
	
}

?>
Ce code est conçu pour fonctionner indépendamment de tout framework, librairie ou base de données, il peut donc être utilisé simplement n'importe où, pour peu que l'architecture générale du site le permette (on suppose dans cet exemple que la page est celle d'un article, qui disposera donc d'un fichier de cache différent pour chaque article) :
<?php
//on récupère par exemple l'ID de l'article en GET, 
//à adapter et sécuriser à votre manière
$articleID = intval($_GET['id']);

//la constante CACHE_PATH est le chemin du répertoire de cache à définir, 
//attention à le créer accessible en écriture
$cache = new cache(CACHE_PATH.'/article.'.$articleID.'.php');
$cache->start();
?>

<html>
<head></head>
<body>
ici le contenu de l'article, PHP, appels à la base etc.
</body>
</html>

<?php
$cache->end();
?>
Dans les cas les plus simples, 3 lignes de code suffisent à implémenter cette technique...

«Oui mais j'ai quand même besoin de PHP dans mes pages!»

Dans l'exemple précédent, on génère à partir de PHP + HTML une page composée uniquement de HTML qu'on stocke dans un dossier de cache pour la re-servir à l'identique à différents visiteurs. Mais voilà, il devient de plus en plus rare de rencontrer un site ou page de site 100% HTML : que ça soit pour gérer des formulaires, des paginations, afficher "bonjour xxx" à un utilisateur connecté, calculer des dates relatives, générer des algorithmes de recherche, etc. on aura toujours besoin de PHP.
Si on observe la classe cache décrite ci-dessus, on s'aperçoit qu'au lieu d'afficher simplement le contenu du fichier de cache (echo file_get_contents($this->cache_path)), on fait un include PHP de celui-ci, ce qui force sa re-évaluation. Mais le fichier caché ne contient-il pourtant pas que du HTML, affichable tel quel?
Non, si dans le fichier d'origine on trouve des instructions PHP du type :
<?php echo '<?php echo $_SESSION[\'username\']; ?>';?>
Dans le fichier mis en cache après une première évaluation on trouvera :
<?php echo $_SESSION['username']; ?>
qui sera ainsi re-évalué à chaque inclusion de page cachée, soit à chaque appel de l'URL par chaque visiteur, afin d'afficher son nom.
Ecrire à la pelle des "echo" de chaînes PHP étant infiniment pénible (entre autres les quotes devant être echappées), on utilisera dans le code initial des tags spécifiques et propriétaires qui seront remplacés au moment de la génération du cache à l'aide d'une expression régulière :
//code placé dans le template original :
<xphp><?php echo $_SESSION['username']; ?></xphp>

//fonction de remplacement :
function replaceXPHP($str){
    if(preg_match_all('/<xphp>(.*)<\/xphp>/Uis', $str, $matches, PREG_SET_ORDER) >0){
        foreach($matches as $match){
            $a = str_replace('\\', '\\\\', $match[1]);								
            $a = str_replace('"', '\"', $a);
            $a = str_replace('$', '\$', $a);
            $replacement = '<?php echo "'.$a.'"; ?>';			
            $str = str_replace($match[0], $replacement, $str);			
        }			
    }
    return $str;
}	
Le tag <xphp> ne contient pas nécessairement que du PHP, et peut même contenir une page PHP + HTML presque entière (par exemple un formulaire). Il peut contenir le résultat d'une requête SQL sous forme de tableau PHP, le résultat d'un calcul complexe sous forme de variable PHP, etc.
Pour mettre cette fonctionnalité en place, on doit modifier la structure de la classe cache précédente : puisque l'on veut finalement implémenter un petit système de templates (remplacement de tags propriétaires), le fichier template doit être externe au script qui l'appelle, ce afin de modifier son code PHP avant toute évaluation.
Cette séparation va dans le sens d'une séparation de logique de contrôle et de logique d'affichage (architecture MVC), et permet de regrouper les méthodes "start" et "end" en un seul appel à une nouvelle méthode "show" :
<?php

class cache{

    private $template_path; //le chemin complet du fichier de template sur le serveur		
    private $cache_path; //le chemin complet du fichier de cache sur le serveur		
    private $expire; //la durée d'expiration, qui vaut par défaut une heure
	
    public function __construct($template_path, $cache_path, $expire = 3600){
        $this->template_path = $template_path;	
        $this->cache_path = $cache_path;
        $this->expire = $expire;		
    }

    //méthode unique de génération de cache et d'affichage de sortie
    public function show(){
        //si le fichier de cache existe est n'a pas expiré, on le renvoie tel quel
        $expire = time() - $this->expire;		
        if(is_file($this->cache_path) && filemtime($this->cache_path) > $expire){
            include $this->cache_path;
            return;
        }
        //vérification du fichier template
        if(!is_file($this->template_path)) 
            die('fichier de template introuvable : '.$this->template_path);		
        //fichier de lock - pour empêcher plusieurs utilisateurs de générer 
        //la page en même temps
        $lock_path = $this->cache_path.'.lock';
        $loop = rand(1, 10);
        $count = 0;
        while(is_file($lock_path)){
            sleep(1);
            $count++;
            if($count >= $loop) break;
        }
        if(self::write($lock_path, 'lock') === false) 
            die('impossible d\'écrire le fichier : '.$lock_path);		
        //remplacement des tags <xphp> et mise en cache
        $content = file_get_contents($this->template_path);
        $content = self::replaceXPHP($content);
        ob_start();
        eval('?>'.$content);
        //on récupère le contenu de la page
        $content = ob_get_contents();
        //vide et désactive le buffer de sortie
        while(@ob_end_clean());
        //enregistre le fichier de cache dernièrement calculé
        self::write($this->cache_path, $content);		
        //supprime le lock
        @unlink($this->cache_path.'.lock'); 	
        //évalue et affiche le contenu du cache
        include $this->cache_path;
    }

    //pour forcer la suppression du cache (back-office...)
    public function clear(){
        @unlink($this->cache_path); 	
    }

    //remplacement des tags <xphp>	
    static private function replaceXPHP($str){
        if(preg_match_all('/<xphp>(.*)<\/xphp>/Uis', $str, $matches, PREG_SET_ORDER) >0){
            foreach($matches as $match){
                $a = str_replace('\\', '\\\\', $match[1]);								
                $a = str_replace('"', '\"', $a);
                $a = str_replace('$', '\$', $a);
                $replacement = '<?php echo "'.$a.'"; ?>';			
                $str = str_replace($match[0], $replacement, $str);			
            }			
        }
        return $str;
    }
	
    static public function write($path, $content){
        if(!$handle = fopen($path, 'w')) return false;
        if(fwrite($handle, $content) === false) return false;
        fclose($handle);
        return true;		
    }
	
}

?>

Exemple simple

Le fichier template pourra contenir une page HTML complète ou seulement un bloc de page destiné à être inclus dans un modèle de templates plus élaboré.
Template
<html>
<head></head>
<body>
ici le contenu de mon article, appels à la base etc.<br/>
variable modifiée à chaque affichage : <xphp><?php echo rand(0,50); ?></xphp><br/>
constante pour tous les visiteurs : <?php echo rand(0,50); ?><br/>
</body>
</html>
Appel PHP au cache
$cache = new cache(TEMPLATE_PATH.'/home.php', CACHE_PATH.'/home.php');
$cache->show();

Exemple réel

Ce système de cache HTML à 2 niveaux d'évaluations a été mis en place sur le site d'arrêt sur images, site à contenu riche et à fort trafic bénéficiant d'une architecture serveur simple (un serveur dédié unique pour la base et les fichiers).
Avec 170.000 pages vues / jour à son lancement, soit environ 47 pages / secondes et probablement plus à présent, le site fournit un affichage fluide en back-office et un affichage quasi-instantané en front-office, et n'a en 2 ans d'existence (so far, so good) connu aucune surcharge notable.