Skip to content
This repository has been archived by the owner on Sep 17, 2024. It is now read-only.

Latest commit

 

History

History
494 lines (354 loc) · 21.1 KB

tutorial_fr.md

File metadata and controls

494 lines (354 loc) · 21.1 KB

Symfony et Mercure

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.

Chat avec un ordinateur

Introduction

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.

Schema Mercure

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) !

Installation de Symfony et des dépendances requises

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 Mercure
  • messenger pour envoyer des messages à Mercure en asynchrone via Messenger
  • lcobucci/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.

Mise en place de Mercure

Installation de Mercure

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 ###

Lancement du Hub

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 à :

Welcome to Mercure!

Vous pouvez couper le Hub, nous le relancerons plus tard.

Ajout d’une couche de sécurité

Création d’un générateur de JWT

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).

Création d’un générateur de cookies

Cookies Monster

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.

Création de la page de Chat

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.

Création du contrôleur de la page d’accueil

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.

Création du contrôleur d’envoi de messages

Distribution de plein de lettres

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.

Derniers réglages

Chat ingénieur appuyant sur un bouton rouge

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)%'

Création du template de la page d’accueil

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

Réception de plein de lettres

Quelques détails sur le code JavaScript

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 topics. 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.

Premiers tests

Pour faire notre premier test, nous avons besoin de :

  1. Lancer le serveur de Symfony pour que la page soit accessible
  2. Lancer le Hub Mercure avec les bonnes informations
  3. 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:

Rendu de la page avec les premiers messages

Ça y est, nous avons une connexion entre notre JavaScript et notre application Symfony via le Mercure Hub.

Pimp My Chat

Maintenant que notre système fonctionne, nous pouvons mettre en place un système d’utilisateurs rudimentaire. Pour cela, il nous faut :

  1. Demander le pseudo de l’utilisateur au chargement de la page
  2. Mettre à jour le système d’envoi des messages pour qu’il prenne en compte ce pseudo
  3. Mettre à jour la publication du message
  4. Annoncer automatiquement l’arrivée de l’utilisateur au Mercure Hub

Bonjour à toi qui souhaite accéder au chat

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.

I hope that someone gets my message in a bottle

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>`);
};

Mise à jour du système d’envoi des messages

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 !

Rendu de la page avec les premiers messages signés

Salutations belle compagnie

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!');
});

Rendu de la page avec un message d'arrivée

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.

This is the end

Bryan Cranston Mic Drop

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 !