Oula, si vous aviez cru que le projet était tombé à l'eau, et bien non. Très très occupé ces derniers mois car je vis maintenant à New York et j'ai pas vraiment eu le temps de rédiger. Ni de trop avancer d'ailleurs.
Mais me revoilà, j'ai pas mal bossé ces dernières semaines et le projet a beaucoup changé depuis la dernière fois mais il est maintenant abouti (enfin). Pour les fidèles (si il en a encore), on retrouve encore et toujours notre road map habituelle :

Road map du projet

  1. Pré-requis et architecture
  2. Configuration de Silex
  3. Jouons avec Silex
  4. Ecriture de tests fonctionnels
  5. TDD pour le code métier
  6. Templating

Aujourd'hui, nous allons aborder la phase finale du projet : le templating mais aussi tout ce qui est gestion du cache pour que l'application soit super rapide. Pour cela, on va utiliser Twig et le moteur de Cache de Symfony 2 qui sont tous les deux fournis par défaut en extension avec Silex.

Voici donc comment j'ai découpé cet article afin de simplifier la lecture.

6. Templating

  • Twig
    • Présentation rapide
    • Silex et Twig
    • Architecture de templates
    • Macros
    • Gérer le cache
  • Cache HTTP
    • Sensibilisation
    • Configurer son cache
  • Bonus: optimiser le chargement de ses pages Web
    • Compression des sources
    • Compression des assets
    • Comparaison
  • Conclusion

Twig

Présentation rapide

Twig est un moteur de template ultra-puissant développé en majeure partie par Fabien Potencier (encore !) qui s'inspire tout droit de Django et Jinja. Cela fait maintenant plus de 3 ans que le projet évolue et s’enrichit en fonctionnalités et flexibilité grâce aux dernières nouveautés de PHP mais aussi de la communauté (hop, on vient de voir 3 des 4 points fondamentaux qui font qu'une techno est mature).

Twig est bien documenté (et de 4 points) et se déploie sagement et progressivement dans les entreprises. En quelques heures à peine, on peut arriver à comprendre comment ça marche et ce que l'on apprécie énormément, c'est l'ambivalence développeur/designer. Les outils sont présents des deux côtés et pas besoin de tout mélanger pour avoir un résultat propre.

Allier simplicité et complexité de template est possible avec Twig puisqu'il embarque une tonne d'outils comme des filtres, des fonctions, des opérateurs, des tests, des macros, des inclusions, des extensions et surtout de l'héritage vertical et horizontal !
Bref, un vrai petit bonheur qui mérite vraiment d'y passer quelques heures pour l'assimiler.

Silex et Twig

Twig est super simple à mettre en place avec Silex car en quelques lignes seulement, le moteur est opérationnel :

// Bootstraping

require_once __DIR__ . '/../vendor/Silex/silex.phar';  
$app = new Silex\Application();

// Cache directory
$app['cache.dir'] = __DIR__ . '/../cache';

use Silex\Provider\TwigServiceProvider;

// Registers Twig extension
$app->register(new TwigServiceProvider(), array(
    'twig.class_path'       => __DIR__ . '/../vendor/Twig/lib',
    'twig.path'             => array(
        __DIR__ . '/views',
        __DIR__ . '/../web/content'
    ),
    'twig.options'          => array(
        'charset'           => 'utf-8',
        'strict_variables'  => true,
        'cache'             => $app['cache.dir']
    )
));

bootstrap.php @master

Ce que j'ai appris récemment, c'est la possibilité de déclarer plusieurs dossiers sources de templates et ainsi, cela m'a permis de supprimer un mini-loader que j'avais codé pour déplacer tous mes fichiers de templates dans le bon répertoire. Un bon RTFM est toujours efficace, même à la fin !

Une fois que tout est bien comme il faut, ce qui est sympa, c'est qu'on peut architecturer nos templates comme on veut et c'est ce que je vais vous présenter maintenant.

Architecture de templates

Étant donné que Twig permet l'héritage, cela permet d'économiser énormément de code et d'éviter de dupliquer du code à droite à gauche. On peut même rendre des blocks spécifiques dans un template si on veut pas tout rendre...

Ci-dessous l'architecture Twig de mon portfolio :

Comme vous pouvez voir, tous les noms de templates commençant par un underscore sont des templates partiels qui sont hérités ou agrégés (inclus) par d'autres templates. Très simplement, on a les 3 pages (index, about et contact) qui sont les pages principales du projet.

Ensuite, nous avons les pages du portfolio qui respectent un modèle commun (model.html.twig) qui héritent d'un layout de galerie.
En fait, on a :

  • model.html.twig a le contenu d'un set (page contenant les photos)
  • _gallery_layout.html.twig structure le contenu d'un set
  • Et selon le cas, cette structuration sera le contenu d'une page à part entière ou restera ainsi.

Le cas numéro 1 représente l'affichage des sets sur l'index (on affiche tous les sets mais pas dans leur intégralité) tandis que le cas numéro 2 représente l'affichage d'un set complet.

Pour cela, on utilise l'héritage conditionnel mais malheureusement, je n'ai pas trouvé si et comment on peut hériter d'un et d'un seul template seulement. Par exemple, j'aurais aimé faire cela :

{# _gallery_layout.html.twig #}
{% if standalone %}
    {% extends '_layout.html.twig' %}
{% endif %}

C'est pourquoi le contenu de [_set.html.twig](https://github.com/eexit/Portfolio/blob/master/src/views/_set.html.twig) est quasiment vide mais si je supprime son contenu, j'ai des erreurs. Si vous avez une astuce ou savez pourquoi, je suis preneur.

Macros

Je crois que ce que j'adore le plus dans Twig, c'est le mappage des méthodes et des objets dans les variables de templates car cela évite de dumper tout le contenu de nos objets dans des variables Twig.

Twig permet de créer des macros afin d'éviter de répéter une même portion de code indéfiniment. Dans le cadre de mon projet, il aurait été inadapté de faire une macro pour un set complet car le code d'un set peut varier à un autre mais typiquement, il est judicieux d'utiliser une macro pour afficher une image par exemple :

{# _macros.html.twig #}
{% macro img(photo, context, title, alt) %}
    {% if photo is defined %}
        {% set default_title = 'Photography © Joris Berthelot' %}
        {% set default_alt = 'This is a photography of the ' ~ context.set.name ~ ' collection' %}
        {% set path = app['smak.portfolio.public_path'] ~ context.set.smak_subpath ~ '/' ~ context.set.getSplInfo().getBasename() ~ '/' ~ photo.getBasename() %}

        <img src="{{ path }}" title="{{ title|default(default_title)|raw }}" alt="{{ alt|default(default_alt) }}" {{ photo.getHtmlAttr()|raw }} />
    {% endif %}
{% endmacro %}

_macro.html.twig @master

Ici, nous voyons que la macro prend en paramètre 4 variables dont seules les 2 premières sont obligatoires (car utilisées sans fallback). Pour la première, il s'agit de la photo (un objet Photo), la seconde étant le contexte d’exécution du template (permet d'avoir accès à toutes les variables du template appelant la macro) et quand aux autres variables, elles parlent d'elles-même.

Ensuite, on définit des valeurs par défaut si les deux derniers paramètres ne sont pas fournis et enfin, on construit le chemin absolu de l'image grâce à l'objet Photo, du contexte et d'une variable de notre application.
Clairement, dans le bout de code suivant, on voit bien comment on remonte dans le contexte pour récupérer le set (un objet Set) :

{{ context.set.getSplInfo().getBasename() }}

Voici un exemple d'appel de la précédente macro :

{# example.twig #}
{% import '_macros.html.twig' as macros %}
{{ macros.img(set.getByName('poney-hits-unicorn'), _context, 'Un poney volant percutant une licorne', 'Une image pleine de couleur et de charme.') }}

Facile non ?
Voici donc une manière super efficace d'éviter de dupliquer du code.

C'est bien beau tous ces template mais comment tous ces appels en cascades peuvent être rapides ?
Merci le cache...

Gérer le cache

Il faut savoir que le cache de Twig n'a rien à voir avec le cache d'op-code de PHP ou le cache HTTP. C'est encore un niveau de cache différent. Twig parse des fichiers templates, les assemble pour obtenir une version finale du code et si le cache est activé, Twig va stocker ces fichiers sur le disque afin d'éviter de tout re-compiler à chaque requête.

Bien que Twig soit super rapide (vieux benchmark), si vous avez des templates qui héritent dans tous les sens avec beaucoup de macros, etc. ça pourrait engendrer des temps de compilation élevés. Pour mettre en place le cache de Twig, il suffit d'assigner une destination à la variable de configuration associée (cf. Silex et Twig).

Bien évidemment, si ces fichiers pré-compilés existent, Twig va les renvoyer quoiqu'il arrive sans forcément regarder si une version plus récente du fichier existe. On pourrait très bien utiliser l'option de configuration [auto_reload](http://twig.sensiolabs.org/doc/api.html#environment-options) qui permet de faire cela mais cette option est indépendante du cache HTTP donc j'ai développé une petite closure qui me permet de faire ce que je souhaitais :

// portfolio.php

// Twig loader (handles last-mod file + re-compile file if not fresh)
$app['twig.template_loader'] = $app->protect(function($template_name) use ($app) {

    // Returns immediately the current time when in debug mode
    if ($app['debug']) {
        return time();
    }

    // Gets the cache file and its modified time
    $cache = $app['twig']->getCacheFilename($template_name);
    $cache_time = is_file($cache) ? filemtime($cache) : 0;

    // If there is a newer version of the template
    if (false === $app['twig']->isTemplateFresh($template_name, $cache_time)) {

        // Deletes the cached file
        @unlink($cache);

        // Flushes the application HTTP cache for the current request
        $app['http_cache']->getStore()->invalidate($app['request']);

        // Returns the cache modified file time (as generated now)
        return time();
    }

    // Returns the template modified time
    return $cache_time;
});

portfolio.php @master

Dans cette closure, on va vérifier si le ficher de cache de notre template n'est pas périmé et si c'est le cas, on le supprime et on supprime aussi l'entrée dans le cache HTTP de l'application. La closure retourne un timestamp Unix qui sera utilisé plus tard.

Sinon en mode debug active normalement l'option auto_reload et si vous avez du cache qui traîne, utilisez les méthodes suivantes :

$app['twig']->clearCacheFiles();
$app['twig']->clearTemplateCache();

Voici donc une manière gérer le cache Twig de votre application Silex mais je suis persuadé qu'on peut mieux faire... Partagez vos idées chers lecteurs !

Après le cache de Twig, on va passer au Cache HTTP qui, lui, est bien plus important encore.

Cache HTTP

Sensibilisation

De nos jours, avec les débits que nous avons et les attentes des utilisateurs, on doit presque tout cacher car une page qui se charge en moins de 3 secondes est généralement zappée. Il faut savoir qu'il n'est pas obligatoire d'utiliser le cache (lequel d'ailleurs ?) mais c'est fortement recommandé car c'est de l'optimisation Web/réseau.

Le cache sur le Web peut intervenir sur plusieurs couches (de la plus proche à la plus distante) :

  • Le cache de votre navigateur Web (disque dur + mémoire vive de votre machine)
  • Le cache de votre FAI ou d'un reverse proxy
  • Le cache d'un CDN
  • Le cache des serveurs Web qui envoient la réponse (disque dur et/ou mémoire vive des serveurs)

Je dois sans doute en oublier car je ne suis pas un expert dans le domaine mais je pense que ce sont les principaux. Quoiqu'il en soit, cacher une page Web ne se fait pas n'importe comment et demande un travail de conception et d'analyse supplémentaire car cela va affecter considérablement le stockage des données une fois envoyées. Si vous êtes curieux, vous pouvez en lire beaucoup plus sur le blog d’Éric Daspet, un expert en performances Web que je recommande fortement.

Si HTTP 1.1 a implémenté le [Cache-Control](http://fr.wikipedia.org/wiki/Cache-Control), c'est pas pour rien, il faut l'utiliser en plus des en-têtes comme Expires ou Last-Modified (plus) car cela affectera énormément le temps de réponse de vos pages Web (de plusieurs secondes à quelques centaines de millisecondes).
Heureusement, le noyau de Symfony 2, qui est construit autour du protocole HTTP, a prévu le coup et englobe de manière simple et élégante la gestion du cache HTTP.

Configurer son cache

Une fois que vous avez configuré l'extension Cache HTTP dans votre application Silex, c'est assez simple de l'utiliser : au lieu de renvoyer directement le rendu d'un template, il suffit de créer un objet Response en lui fournissant les en-têtes qui vont bien et le tour est joué :

// boostrap.php

$app['cache.max_age'] = 3600 * 24 * 90;
$app['cache.expires'] = 3600 * 24 * 90;
$app['cache.dir'] = __DIR__ . '/../cache';

use Silex\Provider\HttpCacheServiceProvider;

// Registers Symfony Cache component extension
$app->register(new HttpCacheServiceProvider(), array(
    'http_cache.cache_dir'  => $app['cache.dir'],
    'http_cache.options'    => array(
        'allow_reload'      => true,
        'allow_revalidate'  => true
)));

// Default cache values
$app['cache.defaults'] = array(
    'Cache-Control'     => sprintf('public, max-age=%d, s-maxage=%d, must-revalidate, proxy-revalidate', $app['cache.max_age'], $app['cache.max_age']),
    'Expires'           => date('r', time() + $app['cache.expires'])
);

Ensuite dans votre contrôleur, c'est aussi simple que cela :

// portfolio.php

use Symfony\Component\HttpFoundation\Response;

$app->get('/', function() use ($app) {
    $template_name = 'index.html.twig';
    $cache_headers = $app['cache.defaults'];
    $sets = $app['smak.portfolio.set_provider']();

    // Updates the Last-Modified HTTP header
    $cache_headers['Last-Modified'] = date('r', $app['twig.template_loader']($template_name));

    // Builds the response
    $response = $app['twig']->render($template_name, array(
        'sets'  => $sets
    ));

    // Sends the response
    return new Response($response, 200, $app['debug'] ? array() : $cache_headers);
});

Avec cela, en fonction de votre environnement de développement, vous aurez des réponses qui demanderont à votre navigateur de les cacher ou non. On voit bien l'utilité de mon loader de template Twig qui permet ici de générer la bonne en-tête.

Enfin, dernière chose... L'extension Cache HTTP de Silex fournit aussi le reverse-proxy de Symfony2 donc pour l'utiliser :

// index.php
$portfolio = require_once __DIR__ . '/../src/portfolio.php';
$portfolio['debug'] ? $portfolio->run() : $portfolio['http_cache']->run();
exit;  

Aussi simplement que cela puisse paraître, vos pages seront désormais cachées si le client accepte le cache. Cette extension couplée avec le cache de Twig et l'application répondra bien plus rapidement !

Bonus: optimiser le chargement de ses pages Web

Dans cette partie, je vais revenir sur des points que j'avais abordés lors de l'article de présentation de mon CV en ligne il y a quelques mois mais cette fois-ci, je vais rédiger en français et ajouter quelques détails.

Compression des sources

Quand je parle de compression de sources, je parle de minimiser le code source afin de rendre encore plus rapide le téléchargement de fichiers. C'est certes discutable mais on peut grattouiller quelques millisecondes en plus, c'est toujours cela de gagner... Puis entre nous, à part les développeurs et les bots qui ont les outils adéquates, qui va regarder le code source de la page ?

Pour minimiser le code HTML, il suffira simplement d'englober le layout dans une balise [spaceless](http://twig.sensiolabs.org/doc/tags/spaceless.html) et le tour sera joué... Twig compile et linéarise le code source.

Pour minimiser le CSS, si vous utilisez des outils comme LESS ou SASS, les fichiers devraient être automatiquement compressés en mode production... Sinon, à l'ancienne, comme moi, avec des compresseurs comme YUI Compressor et un petit script fait maison :

#!/bin/sh
path=`pwd`  
compressor=$path/vendor/yuicompressor/yuicompressor-2.4.7.jar  
css_path=$path/web/ui/styles  
js_path=$path/web/ui/js  
echo "Portfolio UI Compression"  
echo "------------------------"  
# CSS Compression
rm -rf $css_path/portfolio*  
cat $css_path/src/bootstrap.min.css >> $css_path/portfolio-min.css.tmp  
cat $css_path/src/portfolio.css >> $css_path/portfolio-min.css.tmp  
java -jar $compressor --type css -o $css_path/portfolio-min.css $css_path/portfolio-min.css.tmp  
java -jar $compressor --type css -o $css_path/portfolio-ns-min.css $css_path/src/portfolio-ns.css  
rm -rf $css_path/*.tmp  
echo "[ OK ] CSS Compression"  
# JS Compression
rm -rf $js_path/portfolio*  
cat $js_path/src/jquery.min.js >> $js_path/portfolio-min.js.tmp  
cat $js_path/src/jquery.ui-min.js >> $js_path/portfolio-min.js.tmp  
cat $js_path/src/jquery.tools.min.js >> $js_path/portfolio-min.js.tmp  
cat $js_path/src/jquery.mousewheel.js >> $js_path/portfolio-min.js.tmp  
cat $js_path/src/jquery.getscrollbarwidth.js >> $js_path/portfolio-min.js.tmp  
cat $js_path/src/jquery.sizes.min.js >> $js_path/portfolio-min.js.tmp  
cat $js_path/src/jquery.easing.1.3.js >> $js_path/portfolio-min.js.tmp  
cat $js_path/src/portfolio.js >> $js_path/portfolio-min.js.tmp  
java -jar $compressor --type js -o $js_path/portfolio-min.js $js_path/portfolio-min.js.tmp  
rm -rf $js_path/*.tmp  
echo "[ OK ] JS Compression"  

yuicompressor.sh @master

Ce petit script concatène les fichiers et compresse le fichier pour obtenir un fichier très léger... Mais surtout afin d'avoir un seul fichier ce qui évite des requêtes HTTP supplémentaires. Bien que le fichier JavaScript soit du coup beaucoup plus gros que si il n'y en avait plusieurs, il ne faut pas oublier que le navigateur télécharge les scripts en cascade et non pas en parallèle comme avec du CSS ou des images (en savoir plus).

Cela allège aussi le code HTML car au lieu d'avoir 50 lignes <script>, on en a qu'une seule et c'est plus propre.

Compression des assets

Pour la compression des assets, il faut travailler les assets en soit :

  • Les images doivent-être compressées au max (utilisez ImageOptim)
  • Générez des sprites pour les icônes/images d'interface (SASS ou LESS le font sinon)
  • Combinez les deux
  • Utilisez la compression HTTP et le cache

Pour la compression HTTP, Apache propose le [mod_deflate](http://httpd.apache.org/docs/2.4/mod/mod_deflate.html) :

# Compression configuration
SetOutputFilter DEFLATE  
BrowserMatch ^Mozilla/4 gzip-only-text/html  
BrowserMatch ^Mozilla/4\.0[678] no-gzip  
BrowserMatch \bMSIE !no-gzip !gzip-only-text/html  
SetEnvIfNoCase Request_URI \  
\.(?:gif|jpe?g|png)$ no-gzip dont-vary
Header append Vary User-Agent env=!dont-vary  

Et quand à la gestion du cache, cela va dépendre de la variation de vos contenus. Dans mon cas, une fois les photos publiées, il y a peu de chance qu'elles soient changées ou mise à jour. Du coup, je peux pousser l'expiration un max avec le [mod_expires](http://httpd.apache.org/docs/2.4/mod/mod_expires.html) :

# Expiration configuration
ExpiresActive On  
ExpiresByType text/css "access plus 90 days"  
ExpiresByType application/javascript "access plus 90 days"  
ExpiresDefault "access plus 10 years"  

.htaccess @master

Je laisse 90 jours pour le CSS et le JavaScript car on sait jamais si je fais des refresh mais dans cas, je peux aussi utiliser la technique du paramètre dans l'URL des fichiers afin de forcer le téléchargement.
C'est mieux de laisser l'application s'occuper de l'expiration des fichiers HTML.

Quand tout sera bien compressé et bien optimisé, un bon indicateur est PageSpeed de Google (aussi disponible en extension Chrome et Firefox) :

Comparaison

Pour vous donner une petite idée du gain de performances, j'ai pris 2 captures d'écran avec le profiling des requêtes HTTP. Dans le premier cas, il s'agit de l'environnement de développement (pas de cache Twig, pas de cache HTTP applicatif, pas de cache HTTP Apache, pas de compression HTTP et pas d’agrégation d'assets). Dans le second cas, tout est optimisé mais dans les deux cas, il ne s'agit pas de la première requête (code HTTP 304).

Voici donc le premier cas :

On se rend compte que l'intégralité du chargement dure 2.67 secondes car c'est du local.
Voici le cas numéro 2 :

Clairement, on passe de 2.67 secondes à 692 millisecondes ! Une seule requête JavaScript (orange), une seule requête CSS (vert). Dans l'ensemble, ce qui prendra toujours le plus de temps, ce sont les fonts donc allez-y mollo avec les polices fantaisies.

J'espère que ce petit comparatif vous aura bien servi et c'est ici la preuve qu'il n'est pas nécessaire d'avoir un gros framework pour faire du bon travail, même en terme de performances.

Conclusion

Ce dernier article achève ce projet qui se sera étalé sur un an (quand même)... Un an pour un si petit projet me direz-vous mais en étant à temps plein dessus, ça aurait pû être fait en 2 mois je pense (ouais, j'suis toujours pas designer hein) mais avec des spécifications bien bouclées dès le début aussi.
En tout cas, j'espère que cela vous a bien plu et surtout n'oubliez pas que le but est de partager les meilleures astuces de développement donc n'hésitez pas à balancer vos avis sur ce projet.

Le code source est bien évidemment disponible sur mon Github et surtout utilisez ce projet comme exemple en plus du Marketplace de KnpLabs. Happy sharing!

Voici une petite vidéo présentatrice du portfolio, il traîne quelques bugs encore avec le handle du slider et Chrome mais je bosse encore dessus :

Et enfin n'hésitez pas à aller voir le portfolio en ligne : http://photography.eexit.net !

Merci beaucoup pour votre fidélité ! J'en profite pour vous relancer : je serai très bientôt à l'écoute du marché donc si vous avez des opportunités à m'offrir, je suis bien entendu très intéressé. Restez en contact avec moi, mes projets et mes aventure via @JorisBerthelot.

‹ Aller à l'étape précédente


Joris Berthelot