Clément Delmas
Développeur web freelance
Nous allons voir aujourd’hui comment mettre en place facilement et rapidement un Chat dans votre projet Symfony. Cette phrase vous dit quelque chose ? C’est sûrement normal puisque c’était la phrase d’introduction de l’article Symfony et WebSockets, publié ici il y a deux ans. Mais deux ans dans le monde du web, c’est une éternité. Et qui dit éternité, dit nouveautés techniques… Ne bougez pas, je vous explique tout ça.
Mercure est un protocole qui se place comme une alternative moderne et pratique aux WebSockets. Il se base sur la technologie Server-Sent Events, disponible dans la grande majorité des navigateurs actuels. Je ne ferai pas de comparatif entre les deux technologies ici, mais je vous recommande un article de Stanko Krtalić Rusendić (en) qui le fait en détails.
Alors comment ça fonctionne ? Voilà un rapide résumé des étapes que nous allons mettre en place ensuite :
Trèves de bavardages, passons à la pratique (puisque vous êtes sûrement là principalement pour ça) !
Nous allons simplement commencer par créer un projet Symfony 5 afsy-mercure
avec la commande suivante :
$ symfony new afsy-mercure --full
Pour ce tutoriel, nous aurons besoin des librairies suivantes :
symfony/mercure
et symfony/mercure-bundle
pour faire la connexion entre Symfony et Mercuremessenger
pour envoyer des messages à Mercure en asynchrone via Messengerlcobucci/jwt
pour la génération de tokens JWT$ composer require mercure messenger lcobucci/jwt
Voilà, maintenant que tout ça est en place, nous allons pouvoir entrer dans le vif du sujet.
Pour installer Mercure, nous allons télécharger la dernière release stable
disponible sur page des releases et l’extraire dans un dossier mercure
.
Cette archive contient deux choses principales :
public
avec les assets nécessaires à la page de débug de MercureAvant de continuer, nous modifions le .gitignore
pour ignorer les updates Mercure :
# .gitignore
###> mercure ###
updates.db
###< mercure ###
Pour lancer l’exécutable, nous avons besoin de plusieurs paramètres :
Le reste des paramètres est disponible dans la documentation du projet GitHub. Nous allons donc exécuter la commande suivante (depuis la racine du projet) :
# Commande de lancement du Mercure Hub
$ ./mercure/mercure --jwt-key='aVerySecretKey' --addr='localhost:3000' --allow-anonymous
# Vous devriez voir ceci à l'écran
INFO[0000] Mercure started addr="localhost:3000" protocol=http
Si vous vous rendez sur la page localhost:3000, vous devriez arriver sur une page très simple qui ressemble à :
Vous pouvez couper le Hub, nous le relancerons plus tard.
Comme vous l’avez vu dans la commande ci-dessus, Mercure utilise les JSON Web Tokens pour sécuriser l’ensemble des échanges. Nous allons donc commencer par créer un service de génération de JWT :
// src/Mercure/JwtProvider.php
namespace App\Mercure;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Hmac\Sha256;
class JwtProvider
{
private $secret;
public function __construct(string $secret)
{
$this->secret = $secret;
}
public function __invoke(): string
{
return (new Builder())
->withClaim('mercure', ['publish' => ['*']])
->getToken(new Sha256(), new Key($this->secret));
}
}
Ce service utilise le Builder
de Lcobucci/JWT
pour générer un token générique. Pourquoi générique ?
Grâce au ['publish' => ['*']]
. En modifiant le ['*']
, on peut spécifier une liste d’urls
sur lesquelles le token peut publier des mises à jour.
A noter : dans la librairie Lcobucci\JWT
, il existe une clé Hmac\Sha384
. Ne l’utilisez pas !
Elle n’est pas (encore?) supportée par le Hub Mercure et vous risqueriez d’avoir des erreurs.
Comme vous pouvez aussi le voir, le JwtProvider
a besoin d’une clé $secret
pour fonctionner.
Pour cela, nous allons modifier les fichiers de configuration de la manière suivante :
# .env - On modifie l'adresse du Mercure Hub et on remplace le JWT token par la clé JWT
MERCURE_PUBLISH_URL=http://localhost:3000/.well-known/mercure
MERCURE_JWT_KEY="I-c4N_H@Z{M3rCuR3}&SymF0nY~1n~AFSY"
# config/packages/mercure.yaml - On remplace :
jwt: '%env(MERCURE_JWT_TOKEN)%'
# par :
jwt_provider: App\Mercure\JwtProvider
# config/services.yaml - On ajoute la configuration du service
App\Mercure\JwtProvider:
arguments:
$secret: '%env(MERCURE_JWT_KEY)%'
Voilà, notre générateur est prêt. Mais avant de pouvoir entrer dans le vif du sujet, nous allons avoir besoin de quelques gâteaux (ou presque).
Pour pouvoir authentifier les utilisateurs qui souhaitent se connecter au Mercure Hub, il y a deux manières de faire :
Authorization
Comme notre Chat est utilisé depuis un navigateur web, nous allons utiliser la méthode la plus simple : les cookies. Et pour cela, nous allons créer un générateur de cookies :
// src/Mercure/CookieGenerator.php
namespace App\Mercure;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key;
use Symfony\Component\HttpFoundation\Cookie;
class CookieGenerator
{
private $secret;
public function __construct(string $secret)
{
$this->secret = $secret;
}
public function generate(): Cookie
{
$token = (new Builder())
->withClaim('mercure', ['subscribe' => ['*']])
->getToken(new Sha256(), new Key($this->secret));
return Cookie::create('mercureAuthorization', $token, 0, '/.well-known/mercure');
}
}
Ce générateur utilise le même Builder
que celui que nous avons utilisé pour le JwtProvider
.
Et le subscribe' => ['*']
permet, lui aussi, de s’abonner à toutes les urls, ou seulement à une liste précise.
Dans le cas d’une application avec des utilisateurs, nous pourrions évidemment filtrer les abonnements
en fonction de l’utilisateur connecté.
Dans le cadre de cet article, nous allons générer un cookie « basique » (non sécurisé et en HttpOnly
)
pour des raisons de simplicité. Mais si vous souhaitez utiliser ces fonctionnalités en production,
il est vivement recommandé de passer l’ensemble en HTTPS.
Comme vous l’avez aussi remarqué, ce service prend en paramètre notre clé secrète et nous allons modifier la configuration des services pour qu’elle soit bien prise en compte :
// config/services.yaml
App\Mercure\CookieGenerator:
arguments:
$secret: '%env(MERCURE_JWT_KEY)%'
Voilà, notre couche de sécurité est prête, nous allons pouvoir passer à la partie visible de l’iceberg.
Maintenant que nous sommes prêts à communiquer via le Mercure Hub, nous allons pouvoir créer nos deux contrôleur principaux : un pour afficher la page d’accueil, ainsi que les messages reçus, et l’autre pour l’envoi de messages.
Pour afficher la page d’accueil, nous avons besoin de :
// src/Controller/IndexController.php
namespace App\Controller;
use App\Mercure\CookieGenerator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
final class IndexController extends AbstractController
{
/**
* @Route("/", name="home")
*/
public function __invoke(CookieGenerator $cookieGenerator): Response
{
$response = $this->render('default/index.html.twig', []);
$response->headers->setCookie($cookieGenerator->generate());
return $response;
}
}
Lors du chargement de ce contrôleur, nous utilisons le CookieGenerator
que nous avons fraîchement créé
pour inclure le cookie dans la réponse retournée. Cela nous permet de nous assurer que la connexion faite au Hub
est sécurisée.
Pour envoyer des messages au Mercure Hub, il y a deux méthodes possibles :
Publisher
du MercureBundleBus
du Messenger de SymfonyNous choisirons ici la seconde solution parce qu’elle permet d’envoyer les messages de manière asynchrone.
// src/Controller/PublishController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
final class PublishController extends AbstractController
{
/**
* @Route("/message", name="sendMessage", methods={"POST"})
*/
public function __invoke(MessageBusInterface $bus, Request $request): RedirectResponse
{
$update = new Update('http://chat.afsy.fr/message', json_encode([
'message' => $request->request->get('message'),
]));
$bus->dispatch($update);
return $this->redirectToRoute('home');
}
}
Dans ce contrôleur, nous enverrons un Update
au MessageBus
avec comme paramètres :
Nous ajoutons une redirection vers la page d’accueil en valeur de retour, pour le cas où quelqu’un arriverait sur cette url.
Comme nous allons avoir besoin de l’url du Mercure Hub dans nos templates, nous allons passer le contenu de
la variable d’environnement à TWIG via une variable globale. Pour cela, nous allons modifier le fichier
config/packages/twig.yaml
dans lequel nous allons stocker le contenu suivant :
# config/packages/twig.yaml
# Après la déclaration du default_path
globals:
mercure_publish_url: '%env(MERCURE_PUBLISH_URL)%'
Maintenant que tout est prêt, nous pouvons créer le template de la page d’accueil dans le fichier
templates/default/index.html.twig
:
{# templates/default/index.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<h1>AFSY - Mercure and Symfony</h1>
<div id="mercure-content-receiver"></div>
<form id="mercure-message-form" action="{{ path('sendMessage') }}" method="post">
<label for="mercure-message-input">Message:</label>
<input type="text" id="mercure-message-input" name="message"/>
<input type="submit" id="mercure-message-btn" value="Send"/>
</form>
{% endblock %}
{% block javascripts %}
<script type="text/javascript">
const _receiver = document.getElementById('mercure-content-receiver');
const _messageInput = document.getElementById('mercure-message-input');
const _sendForm = document.getElementById('mercure-message-form');
const sendMessage = (message) => {
if (message === '') {
return;
}
fetch(_sendForm.action, {
method: _sendForm.method,
body: 'message=' + message,
headers: new Headers({
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
})
}).then(() => {
_messageInput.value = '';
});
};
_sendForm.onsubmit = (evt) => {
sendMessage(_messageInput.value);
evt.preventDefault();
return false;
};
const url = new URL('{{ mercure_publish_url }}');
url.searchParams.append('topic', 'http://chat.afsy.fr/message');
const eventSource = new EventSource(url, { withCredentials: true });
eventSource.onmessage = (evt) => {
const data = JSON.parse(evt.data);
if (!data.message) {
return;
}
_receiver.insertAdjacentHTML('beforeend', `<div class="message">${data.message}</div>`);
};
</script>
{% endblock %}
Le code HTML ne contient que les éléments nécessaires au fonctionnement de la page :
Le code JavaScript, quand à lui, se divise en quatre parties principales :
sendMessage
qui, comme son nom l’indique, va envoyer un message en HTTPDans le code que nous venons d’écrire, le système d’envoi du message est fait dans une fonction
pour que nous puissions le réutiliser. Nous surchargeons la méthode onsubmit
du formulaire
pour envoyer le message directement et, surtout, pour éviter de recharger continuellement la page.
L’initialisation de la connexion se fait via une URL
. Elle s’abonne aux mises à jour d’un certain nombre de topic
s.
Dans notre cas, il n’y en a qu’un seul, mais on peut bien sûr en mettre autant que l’on souhaite
(en dupliquant et modifiant cette ligne). Cette URL
va être ensuite connectée à un EventSource
pour
pouvoir récupérer les messages avec la méthode onmessage
.
Le second paramètre du constructeur de l’EventSource
est très important. Avec { withCredentials: true }
,
l’EventSource
transmettra automatiquement le cookie de notre page au Mercure Hub.
Sans ce paramètre, votre code ne fonctionnera sûrement pas.
A noter : l'implémentation native de l'EventSource n'autorise pas les entêtes spécifiques (comme un token Bearer par exemple). Pour le faire, vous devrez utiliser un polyfill.
Enfin, dans la méthode onmessage
, nous parsons les données de l’événement et insérons le message envoyé
directement dans le _receiver
en utilisant les
Littéraux de gabarits.
Pour faire notre premier test, nous avons besoin de :
$ symfony server:start -d
$ ./mercure/mercure --jwt-key "I-c4N_H@Z{M3rCuR3}&SymF0nY~1n~AFSY" --addr "localhost:3000"
--cors-allowed-origins "http://localhost:8000" --publish-allowed-origins "*"
On utilise ici le paramètre --cors-allowed-origins
pour spécifier quels domaines peuvent envoyer des messages au Hub.
Dans notre cas, http://localhost:8000
est l’adresse locale du serveur Symfony.
Il ne nous reste maintenant plus qu’à consulter la page que nous venons de modifier pour voir le résultat suivant:
Ça y est, nous avons une connexion entre notre JavaScript et notre application Symfony via le Mercure Hub.
Maintenant que notre système fonctionne, nous pouvons mettre en place un système d’utilisateurs rudimentaire. Pour cela, il nous faut :
Pour demander à l’utilisateur son identifiant, nous allons simplement utiliser un prompt
en JavaScript :
// templates/default/index.html.twig
// A placer juste avant la déclaration du _receiver
const userName = prompt('Hi! I need your name for the Chat please :)');
Bien évidemment, dans une « vraie » application, le système serait différent : l’utilisateur aurait dû créer son compte en amont et il y aurait d’autres vérifications à faire.
Pour envoyer le nom de l’utilisateur, nous modifions les méthodes :
sendMessage
, pour envoyer l’utilisateur souhaité :// templates/default/index.html.twig
// Modification de la signature pour ajouter l'utilisateur avec une valeur par défaut
const sendMessage = (message, user = 'ChatBot') => {
// ...
// Et du body envoyé
body: 'message=' + message + '&user=' + user,
};
_sendForm.onsubmit
, pour y ajouter l’envoi du userName
:// templates/default/index.html.twig
_sendForm.onsubmit = (evt) => {
sendMessage(_messageInput.value, userName);
eventSource.onmessage
, pour ajouter l’utilisateur reçu :// templates/default/index.html.twig
eventSource.onmessage = (evt) => {
const data = JSON.parse(evt.data);
if (!data.message || !data.user) {
return;
}
_receiver.insertAdjacentHTML('beforeend', `
<div class="message">${data.user}: ${data.message}</div>`);
};
Maintenant que notre user
est envoyé en POST
, modifions l’Update
du PublishController
pour qu'il soit pris en compte :
// src/Controller/PublishController.php
$update = new Update('http://chat.afsy.fr/message', json_encode([
'user' => $request->request->get('user'),
'message' => $request->request->get('message'),
]));
Et voilà, nous pouvons maintenant rafraîchir la page pour vérifier que tout fonctionne à merveille !
Pour annoncer automatiquement la venue de l’utilisateur, nous allons ajouter un EventListener
sur l’événement DOMContentLoaded
:
// templates/default/index.html.twig
// A placer avant la fermeture de la balise <script>
window.addEventListener('DOMContentLoaded', () => {
sendMessage(userName + ' joined!');
});
Et c’est tout ! Vous pouvez rafraîchir la page une dernière fois pour vérifier que les messages sont bien envoyés avec votre pseudo. Vous pouvez aussi ouvrir plusieurs fenêtres de navigateur pour tester que les messages s’envoient bien.
Et voilà, nous arrivons à la fin de cet article. L’interface de ce Chat relève clairement du strict minimum mais elle vous permet de comprendre comment tout est mis en place. Si le sujet vous intéresse, j'ai créé un projet GitHub avec l'intégralité de ce que nous avons vu (et sûrement quelques développements en plus ^^).
Je trouve que Mercure est une technologie très intéressante, facile et rapide à déployer. Elle est très différente des WebSockets et répond à d'autres besoins. Dans le cas d'un Chat par exemple, je pense que les WebSockets seraient plus intéressants, notamment grâce à la détection de présence en ligne des utilisateurs. Mais, au delà de cet exemple, je suis persuadé que Mercure Hub sera plus adopté par la communauté parce qu'il apporte une fonctionnalité que tout le monde veut aujourd'hui : l'instantanéité. D’ailleurs, j’en profite pour remercier Kévin Dunglas pour tout ce qu’il fait pour la communauté, dont Mercure… et aussi pour m’avoir aidé pour cet article.
J’espère que ce tutoriel vous aura plus. Je vous remercie de l’avoir lu jusqu’au bout et il ne me reste plus qu’à vous souhaiter de belles fêtes de fin d’année !