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 :
- Création et lancement du Mercure Hub
- Inscription d’un terminal sur ce Hub et souscription à une (ou plusieurs) url(s)
- Envoi d’un message du serveur vers le hub
- Dispatch automatique du message par le hub
- Récupération et traitement du message côté front
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
etsymfony/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 symfony/mercure symfony/mercure-bundle messenger lcobucci/jwt
Voilà, maintenant que tout ça est en place, nous allons pouvoir vraiment rentrer dans le 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 2 choses principales :
- un dossier
public
avec les assets nécessaires à la page de débug de Mercure - et l’exécutable qui est le fameux hub dont nous avons parlé.
Avant 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 :
- Une clé JSON Web Token (JWT_KEY) pour garantir la sécurité
- Une adresse locale (ADDR) pour que le hub soit accessible
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 :
- En utilisant une entête HTTP
Authorization
- Avec un cookie
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 :
- générer un cookie et l’envoyer dans les entêtes de la réponse
- afficher le template principal
// 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 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 :
- utiliser le
Publisher
du MercureBundle - le remplacer par le
Bus
du Messenger de Symfony
Nous 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 :
- l’url du topic que nous avons défini
- les paramètres à envoyer, encodés en JSON
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 titre de la page
- un bloc qui servira de receveur de messages
- un formulaire pour l’envoi de messages
Le code JavaScript, quand à lui, se divise en quatre parties principales :
- L’initialisation des variables nécessaires
- La création d’une fonction
sendMessage
qui, comme son nom l’indique, va envoyer un message en AJAX - L’envoi d’un message à la soumission du formulaire
- L’initialisation de la connexion au Mercure Hub
- La récupération et l’affichage d’un message reçu
Dans le code que nous venons d’écrire, l’envoi du message en AJAX 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.
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 :
- Lancer le serveur de Symfony pour que la page soit accessible
- Lancer le Hub Mercure avec les bonnes informations
- Et c’est déjà pas mal !
$ 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 :
- Demander le pseudo de l’utilisateur au chargement de la page
- Mettre à jour le système d’envoi des messages pour qu’il prenne en compte ce pseudo
- Mettre à jour la publication du message
- Annoncer automatiquement l’arrivée de l’utilisateur au Mercure Hub
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);
Et 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 le prendre 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 rafraichir 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 ça vous intéresse, j'ai créé un projet GitHub avec l'intégralité de ce que nous avons vu (et sûrement quelques trucs 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 ça, 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 profile 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 !