Introduction
Qu'allons-nous faire ?
Mercure est un protocole permettant une communication client-server en temps réel. Utile par exemple pour envoyer des notifications, ou encore pour connaître en live le nombre d'articles restant dans un stock, sans jamais avoir besoin de recharger notre page.
Le but de ce tutoriel est de maîtriser le protocole Mercure. Pour cela, nous allons le combiner au framework Symfony pour créer un petit système de chat, en temps réel bien entendu.
C'est une suite à l'article de découverte de Mercure, que vous retrouverez sur notre blog.
Voici les étapes que nous allons suivre au cours de ce tutoriel :
- Initialisation du projet
- Création des vues et de la logique de base
- Envoi de messages
- Configuration de Mercure
- Discovery, abonnement et publication avec Mercure
- Gestion de la sécurité avec Mercure
- Conclusion
Si une partie ne vous intéresse pas ou ne vous paraît pas nécessaire, il vous sera toujours possible de l'ignorer et de vous rendre à la prochaine étape, dont vous pourrez récupérer l'état actuel du code sur une branche dédiée. Le code source du projet est d'ailleurs disponible sur mon GitHub :
Pré-requis
Pour les besoins de ce tutoriel il vous faudra :
- Avoir des bases en Symfony
- Avoir lu notre article pour connaître les bases de Mercure et de son fonctionnement
- Avoir Docker et docker-compose d'installés sur votre PC
Tout sera expliqué pas à pas, mais vous pouvez tout de même vous accompagner de ces documentations pendant le tutoriel si quelque chose ne vous paraît pas clair :
Le tout est développé avec PHP 7.4
Initialisation du projet
Dans cette partie, nous récupérons la base du code et nous créons nos entités.
Récupération du sample
Pour commencer, vous pouvez cloner le sample de code que je vous ai créé sur ce repo GitHub.
Et pour lancer l'application ?
Nous utiliserons Docker !
La configuration Docker est déjà présente, dans le dossier docker/ et le fichier docker-compose.yml.
Jetez un œil au fichier docker-compose.yml.
Quatre services y sont définis (le serveur nginx, l'application Symfony, la base de données postresql et mercure).
Notez que des variables d'environnement sont nécessaires à certains de ces services. Tout ou presque est défini dans le fichier .env, mais nous y reviendrons pour le compléter.
C'est bon vous êtes enfin autorisés à lancer la commande tant attendue :
docker-compose up -d
Les services vont chacun automatiquement se builder. Ensuite, afin d'installer les dépendances dans le container php il vous faudra faire :
docker-compose exec php composer i
Et voilà, rendez-vous sur votre localhost:81 pour voir votre page d'accueil.
L'application tourne, mais elle est bien vide... Seule la gestion des utilisateurs a été créée pour vous (je ne vais pas vous mâcher tout le travail non plus).
Créons quelques entités en amont et les pages HTML correspondantes en tant que squelette de notre chat !
Création des entités
Listons les entités dont nous allons avoir besoin pour une messagerie :
- Une entité
Messagebien évidemment, cœur de nos échanges. - Une entité
Channel, pour pouvoir choisir sur quel canal discuter.
Et... C'est tout ! Oui, c'est très minimaliste, mais c'est pour ne pas perdre de vue notre objectif, Mercure !
Avec la commande make:entity, suivez les instructions pour créer ces deux entités.
Reportez-vous à ces fichiers de classe pour ajouter les propriétés depuis la console :
// Message.php <?php declare(strict_types=1); namespace App\Entity; use App\Repository\MessageRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\UserInterface; /** * @ORM\Entity(repositoryClass=MessageRepository::class) */ class Message { /** * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") */ private int $id; /** * @ORM\Column(type="string", length=255) */ private string $content; /** * @ORM\ManyToOne(targetEntity=User::class, inversedBy="messages") * @ORM\JoinColumn(nullable=false) */ private UserInterface $author; /** * @ORM\Column(type="datetime") */ private \DateTimeInterface $createdAt; /** * @ORM\ManyToOne(targetEntity=Channel::class, inversedBy="messages") * @ORM\JoinColumn(nullable=false) */ private Channel $channel; // Créez l'entité Channel pour pouvoir ajouter cette propriété public function __construct() { $this->createdAt = new \DateTime(); // Initialisation de la date à chaque nouveau message } // ... getters and setters }
// Channel.php <?php declare(strict_types=1); namespace App\Entity; use App\Repository\ChannelRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity(repositoryClass=ChannelRepository::class) */ class Channel { /** * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") */ private int $id; /** * @ORM\Column(type="string", length=255) */ private string $name; /** * @ORM\OneToMany(targetEntity=Message::class, mappedBy="channel", orphanRemoval=true) */ private Collection $messages; // Créez l'entité Message pour pouvoir ajouter cette propriété public function __construct() { $this->messages = new ArrayCollection(); } // ... getters and setters }
Parfait, nos entités sont créées, n'oubliez pas les relations ! Vérifiez que les repository ont bien été générés également.
Et hop, on peut créer la base de données :
docker-compose exec php bin/console do:da:cr # Pour doctrine database create docker-compose exec php bin/console make:migration # Pour générer les migrations docker-compose exec php bin/console do:mi:mi # Pour appliquer les migrations à la DB
Vous pouvez vous rendre sur cette branche pour être à jour sur cette étape du tutoriel, et continuer sereinement vers la prochaine partie.
Vues et logique de base
Création des controllers
Avant de faire intervenir Mercure, il nous faut un minimum de logique. Créons alors nos deux controllers correspondants à nos entités :
docker-compose exec php bin/console make:controller
Vos controllers MessageController et ChannelController sont créés, et avec eux des templates twig ont été générés, que nous allons remplir avec le minimum syndical pour un chat.
Remplissage des templates
Créez un template supplémentaire templates/channel/chat.html.twig. Nous n'aurons en réalité besoin que de celui-là et de templates/channel/index.html.twig. Ces deux pages seront suffisantes pour toute l'application.
Voici ce que je vous propose pour vos templates, mais vous pouvez bien entendu leur donner la forme que vous voulez (d'autant plus que mes compétences en UI sont... limitées) :
{# templates/channel/index.html.twig #} {% extends 'base.html.twig' %} {% block title %}Home{% endblock %} {% block body %} <div class="container"> {% if app.user %} <div class="mb-3"> You are logged in as {{ app.user.username }}, <a href="{{ path('app_logout') }}">Logout</a> </div> {% endif %} {% if channels %} <h2>Which chan do you want to join ?</h2> <table class="table table-striped"> <tbody> {% for channel in channels %} <tr class=""> <th> <span>{{ channel.name }}</span> <a class="btn btn-primary float-right" href="{{ path('chat', {id: channel.id}) }}">Go chat !</a> </th> </tr> {% endfor %} </tbody> </table> {% else %} <div> <div class="alert alert-danger text-center">No Channels found.</div> </div> {% endif %} </div> {% endblock %}
Dans index.html.twig, on ajoute des informations sur l'utilisateur courant (ce qui nous permettra par la même occasion de pouvoir le déconnecter), et de quoi afficher simplement nos futurs canaux de discussion.
Pour le moment, la page affiche une erreur car nous tentons d'accéder à la variable channels. Injectons-la dans le template depuis notre controller ChannelController.
/** * @Route("/", name="home") */ public function getChannels(ChannelRepository $channelRepository): Response { $channels = $channelRepository->findAll(); return $this->render('channel/index.html.twig', [ 'channels' => $channels ?? [] ]); }
N'oubliez pas d'importer le composant HttpFoundation\Response et le ChannelRepository.
Avec l'annotation @Route, on définit cette page comme étant notre page d'accueil.
Et nous utilisons simplement ChannelRepository pour retourner la liste de tous les canaux.
Il n'y a aucun channel pour le moment, on y viendra. Remplissez pour l'instant le dernier template :
{# templates/channel/chat.html.twig #} {% extends 'base.html.twig' %} {% block title %}Chat{% endblock %} {% block body %} <div class="container"> {% if app.user %} <div class="mb-3"> You are logged in as {{ app.user.username }}, <a href="{{ path('app_logout') }}">Logout</a> </div> {% endif %} <h1>Channel {{ channel.name }}</h1> <div class="container" style="height: 600px"> <div class="container bg-light h-75 overflow-auto"> {% for message in messages %} {% if app.user == message.author %} <div class="row w-75 float-right"> <b>{{ message.author.username }}</b> <p class="alert alert-info w-100"> {{ message.content }} </p> </div> {% else %} <div class="row w-75 float-left"> <b>{{ message.author.username }}</b> <p class="alert alert-success w-100"> {{ message.content }} </p> </div> {% endif %} {% endfor %} </div> <div> <form id="form" class="container row"> <input id="message" class="input-group-text col-sm-9" placeholder="Message" type="text" /> <button id="submit" type="submit" class="btn btn-success col-sm-3">Send</button> </form> </div> </div> </div> {% endblock %}
On y remet les informations de l'utilisateur courant, et surtout, un affichage conditionnel de nos futurs messages, en fonction de l'auteur du message.
On décide d'afficher l'auteur et le contenu du message.
Justement, ajoutez enfin la fonction chat dans le ChannelController afin de récupérer ces futurs messages :
/** * @Route("/chat/{id}", name="chat") */ public function chat( Channel $channel, MessageRepository $messageRepository ): Response { $messages = $messageRepository->findBy([ 'channel' => $channel ], ['createdAt' => 'ASC']); return $this->render('channel/chat.html.twig', [ 'channel' => $channel, 'messages' => $messages ]); }
On n'oublie pas d'importer MessageRepository, et le tour est joué (on pourra accéder à cette page quand des channels seront créés, on y vient).
Créer nos channels
Pour nous embêter le moins possible, nous créerons nos channel avec une commande simple.
Il vous suffit de créer ces deux fichiers de commande dans App\Command.
Le premier pour créer nos channels :
// App\Command\CreateChannelCommand <?php declare(strict_types=1); namespace App\Command; use App\Entity\Channel; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class CreateChannelCommand extends Command { private EntityManagerInterface $em; public function __construct(EntityManagerInterface $em, string $name = 'create:channel') { parent::__construct($name); $this->em = $em; } public function configure(): void { $this ->setDescription('Creates a new channel') ->setDefinition( [ new InputArgument('name', InputArgument::REQUIRED, 'name') ] ) ->setHelp( <<<'EOT' The <info>create:channel</info> command creates a channel with an <info>name</info> argument EOT ); } public function execute(InputInterface $input, OutputInterface $output): int { $name = $input->getArgument('name'); $channel = $this->em->getRepository(Channel::class)->findOneBy([ 'name' => $name ]); if ($channel) { throw new \Exception('Channel already exists'); } $channel = (new Channel()) ->setName($name); $this->em->persist($channel); $this->em->flush(); return 0; } }
Le deuxième pour les supprimer au besoin :
<?php declare(strict_types=1); namespace App\Command; use App\Entity\Channel; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class DeleteChannelCommand extends Command { private EntityManagerInterface $em; public function __construct(EntityManagerInterface $em, string $name = 'delete:channel') { parent::__construct($name); $this->em = $em; } public function configure(): void { $this ->setDescription('Deletes a channel') ->setDefinition([ new InputArgument('name', InputArgument::REQUIRED, 'name') ]) ->setHelp( <<<'EOT' The <info>delete:channel</info> command delete a channel regarding an <info>name</info> argument EOT ); } public function execute(InputInterface $input, OutputInterface $output): int { $name = $input->getArgument('name'); $channel = $this->em->getRepository(Channel::class)->findOneBy([ 'name' => $name ]); if (!$channel) { throw new \Exception('Channel does not exist.'); } $this->em->remove($channel); $this->em->flush(); return 0; } }
Et voilà ! Vous pouvez à présent gérer vos channels à l'aide de ces commandes :
docker-compose exec php bin/console create:channel channel1 #Create a channel docker-compose exec php bin/console delete:channel channel1 # Delete a channel
Dans la prochaine partie nous implémentons la logique d'envoi des messages !
Vous pouvez vous rendre sur cette branche pour être à jour sur cette étape du tutoriel, et continuer sereinement vers la prochaine partie.
Envoi de message
À la fin de cette partie, on sera capable d'envoyer nos messages, et nous serons fin prêts à faire intervenir Mercure.
Poster les messages
Pour poster nos messages, nous devons pouvoir les envoyer depuis notre page de chat. Cependant, on souhaite que notre page ne se recharge pas ! Ainsi, nous pourrons profiter du temps réel avec Mercure dans de bonnes conditions. Nous devons donc faire intervenir un peu de JavaScript.
Dans templates/channel/chat.html.twig, ajoutez ce morceau de script, en lisant bien mes commentaires pour comprendre ce qui se passe :
// Dans une balise <script>, et un block {% block javascripts %} let chatDiv = document.querySelector('.overflow-auto'); chatDiv.scrollTop = chatDiv.scrollHeight; // On souhaite scroller toujours jusqu'au dernier message du chat let form = document.getElementById('form'); function handleForm(event) { event.preventDefault(); // Empêche la page de se rafraîchir après le submit du formulaire } form.addEventListener('submit', handleForm); const submit = document.querySelector('button'); submit.onclick = e => { // On change le comportement du submit const message = document.getElementById('message'); // Récupération du message dans l'input correspondant const data = { // La variable data sera envoyée au controller 'content': message.value, // On transmet le message... 'channel': {{ channel.id }} // ... Et le canal correspondant } console.log(data); // Pour vérifier vos informations fetch('/message', { // On envoie avec un post nos datas sur le endpoint /message de notre application method: 'POST', body: JSON.stringify(data) // On envoie les data sous format JSON }).then((response) => { message.value = ''; console.log(response); }); }
Après avoir empêché le navigateur de rafraîchir la page, on utilise l'API fetch et la méthode POST afin d'envoyer le message au serveur et passer outre le fonctionnement natif des formulaires Symfony. Ainsi notre comportement se rapproche un peu plus d'un client communiquant avec son API.
On souhaite que seuls les utilisateurs authentifiés puissent envoyer des messages, alors mettez à jour le pare-feu de security.yaml :
security: # ... access_control: - { path: ^/chat, roles: ROLE_USER }
Récupérer les messages
Super. L'envoi est fonctionnel et sécurisé. Maintenant, récupérons nos messages depuis le MessageController, via une action sendMessage en méthode POST :
/** * @Route("/message", name="message", methods={"POST"}) */ public function sendMessage( Request $request, ChannelRepository $channelRepository, SerializerInterface $serializer, EntityManagerInterface $em): JsonResponse { $data = \json_decode($request->getContent(), true); // On récupère les data postées et on les déserialize if (empty($content = $data['content'])) { throw new AccessDeniedHttpException('No data sent'); } $channel = $channelRepository->findOneBy([ 'id' => $data['channel'] // On cherche à savoir de quel channel provient le message ]); if (!$channel) { throw new AccessDeniedHttpException('Message have to be sent on a specific channel'); } $message = new Message(); // Après validation, on crée le nouveau message $message->setContent($content); $message->setChannel($channel); $message->setAuthor($this->getUser()); // On lui attribue comme auteur l'utilisateur courant $em->persist($message); $em->flush(); // Sauvegarde du nouvel objet en DB $jsonMessage = $serializer->serialize($message, 'json', [ 'groups' => ['message'] // On serialize la réponse avant de la renvoyer ]); return new JsonResponse( // Enfin, on retourne la réponse $jsonMessage, Response::HTTP_OK, [], true ); }
Après avoir décodé le message reçu et avoir validé qu'il corresponde aux données attendues, on a créé un nouvel objet Message, qu'on a serializé avec le serialization group message. Puis on le retourne donc sous format Json.
Pour savoir quelles propriétés de notre entité on souhaite serializer, on utilise l'annotation @Groups() sur ces dernières, comme ceci :
/** * @ORM\Column(type="string", length=255) * @Groups("message") */ private string $content;
À minima, ajoutez ce groupe sur les propriétés $id, $content, $channel, $author de l'entité Message.
Vous pouvez dès à présent tenter d'envoyer un message. Rafraîchissez la page afin de constater que le message a bien été envoyé, et s'affiche bien dans le chat.
Pas très pratique d'avoir fait tout ca pour simplement recharger manuellement notre page
Il est temps de passer au temps réel avec Mercure.
Vous pouvez vous rendre sur cette branche pour être à jour sur cette étape du tutoriel, et continuer sereinement vers la prochaine partie.
Configuration de Mercure
Mercure va nous permettre d'envoyer et recevoir en temps réel nos messages, sans avoir à reloader les navigateurs déjà connectés au chat.
Dans un premier temps, il nous faut le package composer qui nous permettra d'interagir avec notre Hub Mercure.
Installation
Vous avez sans doute remarqué des warnings dans votre console à propos de variables d'environnement Mercure.
Il est temps de s'en occuper en installant le package mercure depuis composer.
docker-compose exec php composer req mercure
De nouvelles variables d'environnement sont apparues dans votre .env. Allez les modifier pour obtenir ce résultat :
MERCURE_PUBLISH_URL=http://mercure/.well-known/mercure MERCURE_JWT_KEY=astronautsKey MERCURE_ALLOW_ANONYMOUS=1 MERCURE_CORS_ALLOWED_ORIGINS=* MERCURE_PUBLISH_ALLOWED_ORIGINS='http://localhost'
En production, ne laissez jamais ces données sensibles en clair dans ce fichier.
Petit rappel tout de même. Avec Mercure, vous décidez d'updates qui seront publiées vers un Hub, avec
pour identifiant un topic. À son tour, le Hub dispatche ces updates à tous les clients abonnés
au topic en question.
Plusieurs points ici :
- Le
PUBLISH_URLest l'adresse du Hub mercure sur lequel on veut publier nos updates - Le
MERCURE_JWT_KEYest la clé secrète qui nous permettra de générer un token JWT - Et avec
PUBLISH_ALLOWED_ORIGINS, on autorise Mercure à accepter les Publishers depuis le domaine localhost
Ici donc, admettons que les topics soient nos différents channels. Ces derniers lanceront des updates au Hub à chaque nouveau message envoyé, en étant identifiés par un topic que nous définirons plus loin.
Autoriser notre application à publier sur le Hub
Si vous vous rappelez bien de l'article de découverte de Mercure, vous savez qu'il est nécessaire de générer un JWT Token afin de pouvoir publier sur le Hub.
Afin de le générer depuis la clé secrète précisée dans le .env, nous allons utiliser le package lcobucci/jwt.
docker-compose exec php composer req lcobucci/jwt
Créons à présent un service invokable pour générer automatiquement notre token JWT :
<?php declare(strict_types=1); namespace App\Service\Mercure; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Key; class JwtProvider { private string $key; public function __construct(string $key) { $this->key = $key; } public function __invoke(): string { $signer = new Sha256(); return (new Builder()) ->withClaim('mercure', ['publish' => ['*']]) ->getToken($signer, new Key($this->key)) ->__toString(); } }
Remarquez que le Claim de notre builder représente le json que nous avions utilisé sur jwt.io dans l'article de blog.
Il se passe ici exactement la même chose, mais dynamiquement avec PHP. En récupérant le token, on le signe avec une signature de type SHA256.
Faites attention à rentrer correctement le claim, avec la bonne clé et la bonne valeur, autrement Mecure vous refusera l'accès au Hub avec une 401 Unauthorized.
Pour injecter automatiquement la clé que ce service attend dans son constructeur, ajoutez ces lignes dans services.yaml :
services: # ... App\Services\Mercure\JwtProvider: arguments: $key: '%env(MERCURE_JWT_KEY)%'
Enfin, rendez-vous dans le fichier de config mercure.yaml qui vous a été généré, et remplacez la config par celle-ci :
mercure: enable_profiler: '%kernel.debug%' hubs: default: url: '%env(MERCURE_PUBLISH_URL)%' jwt_provider: App\Services\Mercure\JwtProvider
Notre service est à présent fonctionnel.
Ainsi c'est notre nouveau service qui sera invoqué pour générer un nouveau JWT et nous autoriser à publier sur le Hub.
D'ailleurs, relancez toute l'application (docker-compose down && docker-compose up -d) afin que les nouvelles variables d'environnement soient bien prises
en compte.
Vous pouvez vous rendre sur cette branche pour être à jour sur cette étape du tutoriel, et continuer sereinement vers la prochaine partie.
Discovery, abonnement et publication avec Mercure
Discovery du Hub Mercure
Notre client (le navigateur web) doit connaître l'URL du Hub pour pouvoir s'abonner à ses updates.
Or, seule notre application Symfony connaît cette adresse, il faut donc la transmettre à nos clients.
Pour cela, on utilise le mécanisme de Discovery de Mercure, en envoyant au client les informations nécessaires de notre Hub.
Rendez-vous dans votre ChannelController, et ajoutez un Link à la réponse de l'action chat, comme ceci :
/** * @Route("/chat/{id}", name="chat") */ public function chat( Request $request, // Autowire the request object Channel $channel, MessageRepository $messageRepository ): Response { $messages = $messageRepository->findBy([ 'channel' => $channel ], ['createdAt' => 'ASC']); $hubUrl = $this->getParameter('mercure.default_hub'); // Mercure automatically define this parameter $this->addLink($request, new Link('mercure', $hubUrl)); // Use the WebLink Component to add this header to the following response return $this->render('channel/chat.html.twig', [ 'channel' => $channel, 'messages' => $messages ]); }
On ajoute un header de type Link à la réponse, avec l'URL de notre Hub. On récupère pour cela l'objet $request de la requête récupérée par le controller.
Il n'y a plus qu'à récupérer cette information dans le template templates/channel/chat.html.twig. Dans une application Client - API classique qui renverrait une réponse HTTP, il suffirait de récupérer les headers en Javascript comme ceci :
const hubUrl = response.headers.get('Link').match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1]; // Cf documentation Symfony - Mercure
Cependant notre application renvoie une réponse Twig, c'est un chouia plus complexe de récupérer ce header :
// In the Hub URL, 'mercure' is used as the docker-compose service name. We replace it by the actual localhost url for the browser. const link = '{{ app.request.attributes.get('_links').getLinksbyRel('mercure')[0].getHref }}' .replace("mercure", "localhost:3000");
Il est ici important de remplacer la partie mercure de l'URL par celle réellement accessible depuis le client (localhost:3000).
Et voilà, on connaît l'URL de notre Hub, qu'on peut stocker dans une variable de type URL :
const url = new URL(link);
S'abonner aux nouveaux messages
Maintenant qu'on connaît l'adresse du Hub, il suffit de définir le topic auquel on souhaite s'abonner. Il est nécessaire pour cela de définir un topic, sous la forme d'une URI, qui représente la ressource que l'on souhaite "écouter". Or, ce sont les nouveaux messages d'un canal de discussion en particulier que nous souhaitons recevoir automatiquement. Choisissons donc arbitrairement ce topic : http://astrochat.com/channel/{id} (en précisant l'Id du channel courant).
Quand nous publierons des updates, il faudra être cohérent et les publier sur ce même topic.
Pour le moment, finissons la partie abonnement. C'est avec l'API Javascript EventSource qu'on écoutera les événements publiés par notre Hub, et que nous traiterons les données. Je propose de traiter le tout comme ceci :
url.searchParams.append('topic', 'http://astrochat.com/channel/{{ channel.id }}'); // On ajoute le topic souhaité aux paramètres de la requête vers le Hub const eventSource = new EventSource(url); // On s'abonne au Hub const appUser = {{ app.user.id }}; eventSource.onmessage = ({data}) => { // On écoute les événements publiés par le Hub const message = JSON.parse(data); // Le contenu des événements est sous format JSON, il faut le parser document.querySelector('.bg-light').insertAdjacentHTML( // On injecte le nouveau message selon le HTML déjà présent plus haut dans notre fichier Twig 'beforeend', appUser === message.author.id ? `<div class="row w-75 float-right"> <b>${message.author.username}</b> <p class="alert alert-info w-100">${message.content}</p> </div>` : `<div class="row w-75 float-left"> <b>${message.author.username}</b> <p class="alert alert-success w-100">${message.content}</p> </div>` ) chatDiv.scrollTop = chatDiv.scrollHeight; // On demande au navigateur de scroller le chat tout en bas pour bien apercevoir le dernier message apparu }
On crée donc notre objet EventSource en lui passant l'URL de notre Hub, et on injecte l'Id du channel depuis la variable Twig correspondante.
La méthode onmessage sera appelée à chaque nouvel événement publié par le Hub.
C'est bon, votre client est capable de recevoir les futures Updates. On injecte simplement une nouvelle div html pour chaque nouveau message afin de peupler le chat au fur et à mesure, sans avoir à rafraîchir la page.
Maintenant, ces messages, il faut les publier sur le Hub depuis le serveur !
Publier les messages sur le Hub
Le package Mercure vient avec certaines méthodes qui rendent extrêmement simples les interactions avec notre Hub, notamment un objet Update pour construire notre Update, et un Publisher pour publier cette dernière sur le Hub.
C'est dans le MessageController que nous allons traiter cela, au moment de la réception d'un nouveau message. Injectez-le PublisherInterface, créez et publiez votre update. Voici comment j'ai modifié l'action sendMessage pour arriver à ce résultat (comme d'habitude, j'ai commenté les lignes qui ont changé) :
// ... use Symfony\Component\Mercure\PublisherInterface; use Symfony\Component\Mercure\Update; // ... /** * @Route("/message", name="message", methods={"POST"}) */ public function sendMessage( Request $request, ChannelRepository $channelRepository, SerializerInterface $serializer, EntityManagerInterface $em, PublisherInterface $publisher ): JsonResponse { // ... $update = new Update( // Création d'une nouvelle update sprintf('http://astrochat.com/channel/%s', // On précise le topic, avec pour Id l'identifiant de notre Channel $channel->getId()), $jsonMessage, // On y passe le message serializer en content value ); $publisher($update); // Le Publisher est un service invokable. On peut publier directement l'update comme cela return new JsonResponse( $jsonMessage, Response::HTTP_OK, [], true ); }
Comme vous le constatez, nous avons précisé le même topic que celui sur lequel notre javascript écoute les événements.
Essayez d'envoyer un message depuis le chat. Normalement, ce dernier devrait apparaître automatiquement ! Votre messagerie fonctionne désormais en temps réel grâce à Mercure !
Ouf, vous avez fait la majorité du chemin, mais ce n'est pas fini ! Quid de la sécurité dans tout ca ?
Justement c'est l'objet de la prochaine et dernière partie, accrochez-vous encore un tout petit peu !
Vous pouvez vous rendre sur cette branche pour être à jour sur cette étape du tutoriel, et continuer sereinement vers la prochaine partie.
Gestion de la sécurité avec Mercure
Update privée
Nous envoyons des messages en temps réel sur notre chat, mais presque n'importe qui peut récupérer nos événements et lire leur contenu. Pas très sécurisé n'est-ce pas ? Pour protéger nos updates, il suffit de rajouter un paramètre au moment de leur création :
$update = new Update( sprintf('http://astrochat.com/channel/%s', $channel->getId()), $jsonMessage, true // Notre update est à présent privée );
Et voilà ! Ou presque... Vous remarquez sûrement que le temps-réel de votre messagerie ne fonctionne plus... En effet, l'update étant à présent privée, il va falloir prouver que l'on est autorisé à la recevoir.
Pour cela, notre serveur doit créer un cookie contenant un JWT qui authentifiera le topic auquel on souhaite s'abonner. Ce cookie sera simplement déposé sur le client (navigateur) pour qu'il puisse requêter le Hub avec les bons droits.
Générer le Cookie
La valeur de ce cookie étant un Jwt, nous allons utiliser le même principe qu'avec notre JwtProvider. Créons un nouveau Service CookieJwtProvider et générons un token à l'intérieur :
<?php declare(strict_types=1); namespace App\Services\Mercure; use App\Entity\Channel; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Key; class CookieJwtProvider { private string $key; public function __construct(string $key) { $this->key = $key; } public function __invoke(Channel $channel): string { $signer = new Sha256(); return (new Builder()) ->withClaim('mercure', ['subscribe' => [sprintf('http://astrochat.com/channel/%s', $channel->getId())]]) // Attention le claim est différent qu'avec le JWTProvider. Ici on précise le topic privé que l'on souhaite avec le droit "d'accès" ->getToken($signer, new Key($this->key)) ->__toString() ; } }
Comme je l'ai précisé, attention au Claim qui est la seule valeur qui change, en comparaison avec notre JwtProvider.
N'oubliez pas d'enregistrer votre nouveau service pour lui préciser son argument dans services.yaml :
services: # ... App\Services\Mercure\CookieJwtProvider: arguments: $key: '%env(MERCURE_JWT_KEY)%'
Votre Cookie doit être envoyé de préférence au moment du discovery. Générez-le donc dans la réponse de l'action chat du ChannelController, en stockant la réponse dans une nouvelle variable comme ceci :
// ... $response = $this->render('channel/chat.html.twig', [ 'channel' => $channel, 'messages' => $messages ]); $response->headers->setCookie( Cookie::create( 'mercureAuthorization', $cookieJwtProvider($channel), new \DateTime('+1day'), '/.well-known/mercure' ) ); return $response;
N'oubliez pas d'injecter votre CookieJwtProvider dans la signature de cette fonction pour pouvoir l'utiliser ici.
S'authentifier auprès du Hub
Un Cookie est donc à présent déposé dans le navigateur grâce à la réponse de notre serveur.
Comment l'utiliser pour prévenir le Hub qu'on a le droit d'écouter notre topic favori ?
Il suffit d'ajouter un paramètre au moment de la création de votre EventSource. Aussi simplement que ca :
const eventSource = new EventSource(url, {withCredentials: true}); // On a ajouté le "withCredentials". Ainsi la requête sera accompagnée du JWT présent dans le cookie !
Et voilà, c'est terminé !
Mais alors... Pourquoi je ne recois plus mes messages en temps réel ?!
Eh bien, c'est à cause d'une dernière petite subtilité. Quand Mercure envoie des Updates privées, il devient également plus sévère au niveau de ses règles CORS, pour lesquelles il faut préciser le domaine qui recevra les updates.
Changez donc simplement dans votre .env la variable d'environnement MERCURE_CORS_ALLOWED_ORIGINS, et attribuez-y la valeur http://localhost:81.
Et maintenant, après avoir relancé l'application, tout devrait fonctionner correctement !
Pour tester une conversation à plusieurs utilisateurs, ouvrez une nouvelle page de navigation privée ou un nouveau navigateur, et connectez-vous avec un autre utilisateur que vous aurez pris soin de créer.
Discutez ! Et voyez comment les messages sont reçus en temps réel sur votre chat. Tout ce qui vous reste à faire, c'est de lui donner un coup de peinture pour qu'il ressemble à quelque chose, et vous pourrez alors être fiers du résultat.
Vous pouvez vous rendre sur cette branche pour récupérer la version finale de l'application.
Conclusion
Merci !
Merci beaucoup d'avoir suivi ce tutoriel jusqu'au bout ! J'espère que ce bout de chemin avec Mercure vous a plu, et que son application dans le contexte d'un chat a été intéressante. J'ai essayé de proposer un use case qui pourrait totalement exister, mais que je n'ai pas encore forcément apercu dans les tutoriels de Mercure existants.
Je vous donne ci-dessous les différentes sources qui m'ont aidé à développer ce tutoriel :
- Documentation Mercure
- Documentation Symfony
- Grafikart : Vidéo tutoriel Mercure (Attention, la version de Mercure dans cette vidéo est à présent obsolète).




