Pourquoi utiliser un cache HTML
La première raison qui vient à l'esprit est une question de bon sens. Lorsque les scripts PHP assemblent le code HTML d'une page, il y a peu de chance que 10 minutes plus tard, il faille à nouveau repartir de zéro. Dans ce cas, autant se resservir des calculs qui ont été effectués et de mettre à jour uniquement certaines parties du template.La deuxième raison est le trafic croissant auquel est soumis un site internet. Entre les internautes, les réseaux sociaux et les crawlers en tout genre, une même page peut être interrogée une dizaine de fois en quelques minutes. Je vous invite à jeter un coup d'oeil au fichier log du serveur pour se rendre compte du nombre de requête pour une même URL. Du coup, autant afficher la même page HTML à chaque fois sans relancer les scripts et les requêtes vers la base de donnée.
Class PHP pour une mise en cache
Pour ce cas pratique, nous avons choisi de stocker les fichiers HTML dans une base Sqlite tout simplement pour le coté pratique. En effet, tout le cache est contenu dans un seul fichier et le moteur SQL permet de faire des requêtes sur les champs associés au fichier HTML.<?php
namespace Vendor\Database;
/**
* Page cache engine using Sqlite DB
*
* @author chris 24/12/2018
*/
use Exception;
use PDO;
class FrontCacheEngine {
/**
* Duree de vie 1 semaine 7 x 24 x 60 x 60
*/
const LIFE_ONEWEEK = 604800;
/**
* Duree de vie 2 semaine 14 x 24 x 60 x 60
*/
const LIFE_TWOWEEK = 1209600;
/**
* Duree de vie 1 mois 30 x 24 x 60 x 60
*/
const LIFE_ONEONTH = 2592000;
/**
* Nom de la DB
*/
const FILE_DB = 'db_front_html_cache.sqlite';
/**
* Insérer ou remplacer le cache HTML
* @param string $uri
* @param string $html
* @param int $quality
* @param int $cache_life
* @return boolean
*/
static public function update(string $uri, string $html, string $note = '', string $db_path = '', string $db_filename = '', int $cache_life = self::LIFE_TWOWEEK): bool {
$SQL = 'INSERT OR REPLACE INTO tb_front_cache (FCA_URI,FCA_HTML,FCA_DATE_CREATED,FCA_DATE_EXPIRED,FCA_ETAG,FCA_NOTE)
values(:FCA_URI,:FCA_HTML,:FCA_DATE_CREATED,:FCA_DATE_EXPIRED,:FCA_ETAG,:FCA_NOTE)';
$P = array();
$ts = time();
$P['FCA_URI'] = trim($uri);
$P['FCA_HTML'] = gzcompress($html);
$P['FCA_DATE_CREATED'] = date('Y-m-d H:i:s');
$P['FCA_DATE_EXPIRED'] = date('Y-m-d H:i:s', ($ts + $cache_life));
$P['FCA_ETAG'] = md5($uri . $P['FCA_DATE_EXPIRED']);
$P['FCA_NOTE'] = $note;
$file_db = self::get_db_filename($db_path, $db_filename);
try {
$pdo = new PDO('sqlite:' . $file_db);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$stmt = $pdo->prepare($SQL);
$b = $stmt->execute($P);
if (!$b) {
parent::log_error_msg('Update cache failed', __CLASS__, __LINE__);
}
return $b;
} catch (Exception $ex) {
return FALSE;
}
}
/**
* Retourner la page en cache
* @param string $uri
* @param string $db_path
* @return array
*/
static public function read(string $uri, string $db_path = '', string $db_filename = ''): array {
$SQL = 'SELECT * FROM tb_front_cache WHERE FCA_URI=:FCA_URI AND FCA_DATE_EXPIRED>:FCA_DATE_EXPIRED LIMIT 1';
$P = array();
$P['FCA_URI'] = $uri;
$P['FCA_DATE_EXPIRED'] = date('Y-m-d H:i:s');
$file_db = self::get_db_filename($db_path, $db_filename);
$rs = [];
try {
$pdo = new PDO('sqlite:' . $file_db);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$stmt = $pdo->prepare($SQL);
$stmt->execute($P);
$rs = $stmt->fetch(PDO::FETCH_ASSOC);
$stmt->closeCursor();
} catch (Exception $ex) {
$rs = [];
}
if (isset($rs['FCA_HTML'])) {
$rs['FCA_HTML'] = gzuncompress($rs['FCA_HTML']);
} else {
$rs = [];
}
return $rs;
}
static public function read_html(string $uri, string $db_path = ''): string {
$rs = self::read($uri, $db_path);
if (isset($rs['FCA_HTML'])) {
return $rs['FCA_HTML'];
} else {
return '';
}
}
static public function compact_db() {
$SQL = 'DELETE FROM tb_cw_search WHERE CWS_DATE_EXPIRED<:CWS_DATE_EXPIRED';
$P = array();
$P['CWS_DATE_EXPIRED'] = date('Y-m-d H:i:s');
$file_db = rtrim($_SERVER['DOCUMENT_ROOT'], DIRECTORY_SEPARATOR) . self::FILE_DB;
try {
$pdo = new PDO('sqlite:' . $file_db);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
//delete old rows
$stmt = $pdo->prepare($SQL);
$b = $stmt->execute($P);
//VACUUM
$stmt = $pdo->prepare('VACUUM');
$b = $stmt->execute();
} catch (Exception $ex) {
$rs = FALSE;
}
}
static public function get_table_status(): array {
$n_stat = array('row_count' => 0);
//Taille de la BDD
$file_db = rtrim($_SERVER['DOCUMENT_ROOT'], DIRECTORY_SEPARATOR) . self::FILE_DB;
$n_stat['file_size'] = filesize($file_db);
try {
$pdo = new PDO('sqlite:' . $file_db);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
//Nombre de ligne
$SQL = "SELECT COUNT(*) as ROWCOUNT FROM tb_cw_search";
$stmt = $pdo->prepare($SQL);
$b = $stmt->execute();
$rs = $stmt->fetch(PDO::FETCH_ASSOC);
$stmt->closeCursor();
if (isset($rs['ROWCOUNT'])) {
$n_stat['row_count'] = (int) $rs['ROWCOUNT'];
}
//cache périmé
$SQL = "SELECT COUNT(*) as ROWCOUNT FROM tb_cw_search WHERE CWS_DATE_EXPIRED<:CWS_DATE_EXPIRED";
$P = array();
$P['CWS_DATE_EXPIRED'] = date('Y-m-d', time()) . ' 00:00:00';
$stmt = $pdo->prepare($SQL);
$b = $stmt->execute($P);
$rs = $stmt->fetch(PDO::FETCH_ASSOC);
$stmt->closeCursor();
if (isset($rs['ROWCOUNT'])) {
$n_stat['expired_count'] = (int) $rs['ROWCOUNT'];
}
return $n_stat;
} catch (Exception $ex) {
$rs = FALSE;
}
foreach ($this->databaseDefinition->getTablenameList() as $table_name) {
$rs = $this->query_custom();
if (isset($rs[0]['ROWCOUNT'])) {
$n_stat[$table_name] = $rs[0]['ROWCOUNT'];
} else {
App_adminModelLogcat::sysWarning('Invalid table name: ' . $table_name, __CLASS__, __LINE__);
}
}
return $n_stat;
}
/**
* Créer la DB Sqlite
* @param string $path
* @return boolean
* @throws Exception
*/
static public function create_db(string $path = '', string $db_filename = '') {
$file_db = self::get_db_filename($path, $db_filename);
try {
$pdo_out = new PDO('sqlite:' . $file_db);
$pdo_out->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
####################################################################
# Table cache
####################################################################
$SQL = "CREATE TABLE IF NOT EXISTS tb_front_cache (";
$SQL .= "FCA_ID INTEGER PRIMARY KEY AUTOINCREMENT,";
$SQL .= "FCA_URI VARCHAR(128) UNIQUE NOT NULL,";
$SQL .= "FCA_HTML TEXT NOT NULL,";
$SQL .= "FCA_DATE_CREATED DATETIME NOT NULL,";
$SQL .= "FCA_DATE_EXPIRED DATETIME NOT NULL,";
$SQL .= "FCA_ETAG VARCHAR(32) UNIQUE NOT NULL,";
$SQL .= "FCA_NOTE TEXT";
$SQL .= ");";
$pdo_out->exec($SQL);
####################################################################
# Test
####################################################################
$stmt = $pdo_out->prepare('PRAGMA table_info(tb_front_cache)');
$stmt->execute();
$rs = $stmt->fetch(PDO::FETCH_ASSOC);
$stmt->closeCursor();
if (!isset($rs['name']) || $rs['name'] != 'FCA_ID') {
throw new Exception('Create DB or table failed: tb_front_cache');
}
return TRUE;
} catch (Exception $ex) {
throw new Exception($ex);
}
}
/**
* Retourner le dossier et fichier de la DB
* @param string $path
* @return string
* @throws Exception
*/
static protected function get_db_filename(string $path = '', string $db_filename = ''): string {
$db_path = '';
//Utiliser le dossier transmis
if (!empty($path)) {
$path = realpath($path);
if (!is_dir($path) && !is_writable($path)) {
throw new Exception('Dir not exists or not writable: ' . $path);
}
$db_path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
}
//Aucun dossier
if (empty($db_path)) {
throw new Exception('Dir not set');
}
//Nom de dla DB
if (!empty($db_filename)) {
return $db_path .$db_filename;
} else {
return $db_path . self::FILE_DB;
}
}
}
Exemple de sauvegarde de page HTML
Pour illustrer l'utilisation de la classe FrontCacheEngine, voici un script qui reprend les trois étapes à suivre:-> créer la base de donnée (à la première utilisation)
-> stocker le code source d'une url courte
-> lire le code HTML mis en cache
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . 'FrontCacheEngine.php';
//Dossier de stockage de Sqlite
$PATH=$_SERVER['DOCUMENT_ROOT'];
//Création de la base de donnée
\Vendor\Database\FrontCacheEngine::create_db($PATH);
//Code source HTML pour l'exemple
//dans les projets, le HTML est généré par le moteur de template
$html=file_get_contents('http://www.fobec.com');
//Sauvegarde de la page
$uri='/';
\Vendor\Database\FrontCacheEngine::update($uri, $html,'',$PATH);
//Lecture
$n_cache=\Vendor\Database\FrontCacheEngine::read($uri,$PATH);
if (isset($n_cache['FCA_HTML'])) {
echo $n_cache['FCA_HTML'];
} else {
//page à regénérer
//cause: absence de la page ou durée de validité dépassée
}
Pour ceux qui souhaitent optimiser le système de cache, la classe intègre la gestion des etag qui sont insérés dans le header de la page internet. L'exemple devra être adapté à votre propre système de génération du code HTML à partir d'un moteur de template ou en directement à la sortie du script controler de l'application.
La mise en cache reste une solution intéressante lorsque les données à mettre à jour sont assez faibles. Par exemple, pour un site web disposant d'une zone membre ou proposant un fil d'actualité, le code source est enregistré dans le cache puis au moment voulu seule la zone membre ou le fil d'actualité est modifié avec l'affichage.
Comme on peut le voir dans l'entête, la class FrontCacheEngine est utilisée depuis deux ans sur un site internet. Du coté des chiffres, la base Sqlite comporte 100 000 pages et le trafic tourne autour du million sur un mois. Pour cette solution facile à mettre en place, les performances sont à mon sens bonne puisque le temps de chargement dépasse rarement les 10 ms et que le script a permis de résister à un pic de trafic de 15 000 pages en 4 minutes !