Rémi Janot
Rémi Janot est Lead Developer Cloud chez Ysance.
Tout fidèle lecteur se précipite sur son ordinateur dès potron-minet pour découvrir l'article qui se cache derrière la case du jour, et dont il interrompra le déroulé prévu du speaker pour lui poser toutes les questions qui lui passent par la tête.
Aujourd'hui, pour compléter l'article de la veille de Xavier sur les API REST, je vous propose de parler un peu de l'intégration d'un MVC côté client sur un site à base de Symfony2.
MVC ! Ce qui signifie Model / View / Controller. Symfony2, comme bien d'autres frameworks, s'appuie sur ce principe. Mais en quoi consiste-t-il ? L'idée principale est de séparer le modèle de données, qui contient entre autres les règles métiers, du rendu final (Interface Utilisateur, format XMl ou JSON, ...), avec une couche intermédiaire de contrôle, qui, en fonction des données d'entrées, appelle le Modèle et la Vue correspondante.
Avec Symfony2, on a par exemple :
N'avez-vous jamais été étonné de la rapidité des changements de page sur Facebook lorsqu'on ouvre une photo, ou bien des temps de chargement sur Twitter ou Github ? C'est parce qu'ils utilisent un MVC côté client que l'expérience utilisateur est aussi agréable (d'un point de vue technique, après du point de vue fonctionnel, c'est un autre troll...).
En déportant les couches MVC du côté client, on obtient donc une API comme Modèle (la plupart du temps une API REST), un Contrôleur et une Vue écrits en JS (par simplicité je n'aborderai pas Dart).
Ainsi, une fois la première page chargée, les transferts avec le serveur sont moins coûteux (quelques kilo-octets de données JSON, contre quelques centaines pour une page html générée côté serveur). Le 56k étant presque révolu, ce n'est pas ici que nous gagnons le plus. En effet, on ne rafraîchit que la partie désirée de la page, et on évite ainsi d'avoir à recharger toute la page (événements Javascript, librairies JS, ...).
Et là, on gagne beaucoup !
De plus, il a pu être aisé de précharger les ressources suivantes, et là on frôle l'instantané !
Quand on parle d'Ajax, en général, c'est avec un impact limité. Par exemple, changement dans la pagination d'une liste, affichage du contenu d'une pop-in.
Un framework MVC côté client permet de généraliser cette pratique sur toutes les parties et toutes les pages du site. A un point que plus aucun lien ne déclenchera l'appel des pages vers lesquels ils pointent. Par ailleurs, comme tous les frameworks, il simplifie le développement. En l'occurrence tout le processus de mise à jour de la page et le routage sont gérés par le framework.
Comme dit plus haut le Modèle côté Javascript est très souvent couplé à une API REST.
Cette API peut donc être écrite en Symfony2.
Vous pouvez choisir votre framework à partir du projet TodoMVC.
Prenons l'exemple de Backbone.
Comme il s'agit d'un exemple, il n'y a pas d'enregistrement côté serveur,
mais en LocalStorage
. Ceci dit, il y a déjà plusieurs
interactions : ajouter un élément, le supprimer, le modifier et le marquer
comme terminé. Puis vous pouvez filtrer les actifs ou terminés, ou ne pas
filtrer.
Alors oui, il n'y a que le hash qui change, mais dans cet exemple ! Maintenant que nous avons un exemple fonctionnel, on va l'améliorer pour en faire une application Symfony2 afin de comprendre les interactions.
D'abord, récupérons un projet Symfony2 et TodoMVC :
$ composer create-project symfony/framework-standard-edition
$ git clone https://github.com/tastejs/todomvc.git
$ cp -r todomvc/architecture-examples/backbone/* framework-standard-edition/web
$ mkdir framework-standard-edition/src/Acme/DemoBundle/Resources/views/Todo
$ mv framework-standard-edition/web/index.html framework-standard-edition/src/Acme/DemoBundle/Resources/views/Todo/index.html.twig
On crée un TodoController :
// src/Acme/DemoBundle/Controller/TodoController.php
<?php
namespace Acme\DemoBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class TodoController extends Controller
{
public function indexAction()
{
return $this->render('AcmeDemoBundle:Todo:index.html.twig');
}
}
Puis le routage :
# src/Acme/DemoBundle/Resources/config/routing.yml;
index:
path: /
defaults: { _controller: AcmeDemoBundle:Todo:index }
et
# app/config/routing.yml
_acme_demo:
resource: "@AcmeDemoBundle/Resources/config/routing.yml"
Dans la classe AppKernel.php, activez le bundle
AcmeDemoBundle
:
<?php
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\Config\Loader\LoaderInterface;
class AppKernel extends Kernel
{
public function registerBundles()
{
$bundles = array(
new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
new Symfony\Bundle\SecurityBundle\SecurityBundle(),
new Symfony\Bundle\TwigBundle\TwigBundle(),
new Symfony\Bundle\MonologBundle\MonologBundle(),
new Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle(),
new Symfony\Bundle\AsseticBundle\AsseticBundle(),
new Doctrine\Bundle\DoctrineBundle\DoctrineBundle(),
new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),
new Yucca\PrerenderBundle\YuccaPrerenderBundle(),
new Acme\DemoBundle\AcmeDemoBundle(),
);
if (in_array($this->getEnvironment(), array('dev', 'test'))) {
$bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle();
$bundles[] = new Sensio\Bundle\DistributionBundle\SensioDistributionBundle();
$bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle();
}
return $bundles;
}
}
Maintenant, nous devons avoir une liste de tâche fonctionnelle. Allez sur votre site :
La liste vous semble un peu vide ? Remplissons là par défaut :
// web/js/collections/todos.js
//....
(function(){
// Create our global collection of **Todos**.
app.todos = new Todos();
app.todos.fetch();
if (0 === app.todos.length) {
var t1 = {
title: 'Créer la section calendrier de l\'avent sur le site de l\'AFSY',
order: app.todos.nextOrder(),
completed: true
};
app.todos.create(t1);
var t2 = {
title: 'Faire un appel à candidature',
order: app.todos.nextOrder(),
completed: true
};
app.todos.create(t2);
var t3 = {
title: 'Rédiger un article sur Prerender.io',
order: app.todos.nextOrder(),
completed: false
};
app.todos.create(t3);
}
})();
Ce fichier contient la collection des tâches. Ainsi dès que l'application sera exécutée, si la liste des tâches est vide, on en crée trois par défaut.
Maintenant, en jouant avec les filtres, en complétant des tâches ou en les supprimant, vous pouvez voir la rapidité d'exécution d'un MVC côté client versus côté serveur.
Ahh bah voilà ! On y est. Pour un intranet ou un extranet, pas de souci de référencement. Mais quand on est en front office, la question du référencement s'impose.
Quand on regarde la source de la page, elle reste désespérément vide de tâches à réaliser. Et bien voici notre première tâche : faire que le HTML rendu aux robots corresponde à ce que voit un humain.
Pour exécuter du javascript côté serveur, il n'y a pas 36 solutions. Nous allons utiliser NodeJS, couplé à PhantomJS. Le projet Prerender.io s'occupe de tout pour nous.
Déployons donc Prerender :
$ git clone https://github.com/collectiveip/prerender.git
$ cd prerender
$ npm install
$ node index.js
Et hop, nous avons un serveur local qui écoute sur le port
3000
. On va s'en assurer en regardant la page à l'adresse
http://localhost:3000/
.
Pour voir le retour de Prerender, il suffit de compléter avec l'adresse à
afficher (et non, il n'y a pas de faute de frappe) :
http://localhost:3000/http://essai-backbone/
.
Ok, ce n'est pas très joli sans le CSS, mais on a bien nos tâches d'inscrites. Mais ça on l'avait déjà avant. L'essentiel est dans - Lactel - la source de la page : on a effectivement nos tâches dedans.
Pas si vite !
Oui dans les sources on a bien nos tâches, mais on n'a plus de balises
<script>
. En effet, il n'est pas souhaitable que du JS
soit exécuté par le crawler des moteurs de recherche. Car c'est en partie
pour eux qu'on a préparé cela, ne l'oublions pas. En conséquence il faut
servir une page différente aux moteurs de recherche et aux utilisateurs.
Ou pas ! Google connait ces problématiques et est à l'origine d'une proposition de crawling pour les pages chargées en Ajax.
Les routes #/active
et #/completed
doivent être
transformées respectivement en #!/active
et
#!/completed
. Ce point d'exclamation indique aux moteurs de
recherche (en tout cas, à ceux qui supportent cette proposition) que ce qui
suit est une route crawlable de notre application. Du coup, ils
réinterprètent l'url et n'appellent pas la même page.
http://essai-backbone/#!/completed
est transformée en
http://essai-backbone/?_escaped_fragment_=/completed
Ce n'est en fait qu'un détail d'implémentation. En effet, en Symfony2, le
routage est enregistré dans la variable globale
$_SERVER['PATH_INFO']
. Il y a donc peu de différence entre le
routage Symfony et le routage pour les robots placé dans la variable
$_GET['escaped_fragment']
.
Il faut juste savoir que deux routes peuvent coexister : celle en Symfony2 qui est la route « par défaut » et qui n'aura pour but final que de rendre une page chargeant du JS, et celle à demander à Prerender dans certains cas : et c'est là que votre serviteur intervient.
Ce sujet m'a intéressé parce que j'ai justement été confronté à ces problèmes, et que malgré toute la littérature existante, ni Voltaire ni Montesquieu ne m'ont apporté de réponse ou de guide clair sur la marche à suivre. Prerender s'occupant du rendu, il nous faut faire le lien entre l'application Symfony2 et Prerender au moins dans les cas suivants :
_escaped_fragment_
User-Agent
)
Le tout en évitant certaines extensions. En effet, on n'aura pas de javascript à exécuter dans un fichier CSS (oui ça a existé dans IE, mais bon)...
Il faut ajouter le bundle :
$ php composer.phar require "yucca/prerender-bundle" "0.1.*@dev"
Puis enregistrer le bundle dans l'application :
// app/AppKernel.php
public function registerBundles()
{
return array(
// ...
new Yucca\PrerenderBundle\YuccaPrerenderBundle(),
);
}
Et enfin modifier la configuration :
#app/config/config.yml
yucca_prerender:
backend_url: http://localhost:3000
Ne pas oublier de vider le cache :)
Du coup, plaçons nous en tant que Google (en tant que robot, pas en tant que
société multimilliardaire, même si vous aimeriez) et voyons ce qu'il se
passe si on tente d'appeler http://essai-backbone/#!/completed
.
Le robot, en voyant cela, va retenir la jolie URL avec le hashbang. Mais son
contenu va être récupéré en appelant
http://essai-backbone/?_escaped_fragment_=/completed
.
Regardons la source de cette adresse : on y voit clairement les tâches, le Middleware a donc fait son travail. Cependant on remarque que la tâche « Rédiger un article sur Prerender.io » est présente, alors que non terminée.
En regardant la sortie console de Prerender, on comprend que l'url
http://essai-backbone/?_escaped_fragment_=/completed
a été retransformée en http://essai-backbone/#!/completed
qui
peut être gérée par Backbone côté serveur. Enfin pas tout à fait, puisqu'il
y a le point d'exclamation (!
) qui vient changer la route de
base.
//web/js/routers/router.js
routes: {
'!\/\*filter': 'setFilter'
},
Après un petit Ctrl + F5, vous me sautez au cou et m'étranglez de joie. Non je déconne un simple coup à boire me suffira !
Bien sûr ! Backbone le gère très bien. Dans le fichier
web/js/routers/router.js
, il suffit de remettre l'ancienne
route ('*filter': 'setFilter'
) et modifier la dernière
ligne :
Backbone.history.start({ pushState: true })
On peut donc avoir comme url http://essai-backbone/completed
.
Mais il faut rajouter les nouvelles routes dans Symfony2 :
# src/Acme/DemoBundle/Resources/config/routing.yml;
index:
path: /
defaults: { _controller: AcmeDemoBundle:Todo:index }
completed:
path: /completed
defaults: { _controller: AcmeDemoBundle:Todo:index }
active:
pattern: /active
defaults: { _controller: AcmeDemoBundle:Todo:index }
On supprime le cache, et on y va. On affiche la source et là, c'est le drame : la liste est vide et Prerender ne reçoit plus d'appel. En effet, nous ne sommes devenus qu'un simple humain demandant une page web. Il faut donc dire aux robots que cette URL n'est pas crawlable à cause d'Ajax. Du coup, il faut inclure une balise méta dans la page :
<!-- src/Acme/DemoBundle/Resources/views/Todo/index.html.twig -->
<meta name="fragment" content="!">
Une fois cette balise incluse, Google saura que le contenu de cette page
doit être récupéré de la même manière que précédemment avec
_escaped_fragment_
.
Mais Facebook lui sera identifié par sa signature (User-Agent
)
et par défaut Prerender s'occupera du rendu de la page. Faîtes l'essai sur
Chrome.
User Agent
.
facebookexternalhit
dans le champ input,
YuccaPrerenderBundle
n'est qu'un middleware entre les robots et
Prerender. Comme on l'a vu, il y a quelques façons de le configurer.
Et c'est tout. Mais couplé à Prerender, ce bundle vous évite les nuits blanches consacrées au référencement de votre site. Mais attention, aujourd'hui je n'ai pas assez de recul pour connaître le réel impact des MVC JS sur le référencement.
De rien !
Ah, et j'oubliais : vous pouvez naturellement le forker et proposer des pull requests. J'essaie au maximum de suivre le développement des autres middlewares (node, rail, ZF2 à ce jour). Mais si vous avez l'idée qui fait toute la différence... bienvenue !