Clément Delmas
Clément est consultant chez Acensi et développeur couteau-suisse.
Nous allons voir aujourd’hui comment mettre en place facilement et rapidement un Chat dans votre projet Symfony. Mais avant de commencer ce tutoriel, reprenons un peu quelques bases.
Dans un site web traditionnel, lorsqu’on veut faire des échanges entre la page web qu’un utilisateur est en train de consulter et le serveur, le tout sans recharger la page, on fait une requête en AJAX. C’est un procédé assez simple à mettre en place mais qui a un gros inconvénient : la communication entre le client et le serveur ne se fait que dans un sens. Le client fait une requête au serveur, qui lui répond en lui apportant ce qu’il a demandé. Le problème, c’est que dans certains cas, la demande du client peut nécessiter du temps. Dans ce cas là, ce client est obligé de faire des requêtes régulières au serveur pour lui demander où en est sa demande. Évidemment, ça ne fait plaisir ni au client, ni au serveur parce que ça nécessite beaucoup plus de ressources que nécessaire.
C’est là que les WebSockets interviennent. Ils permettent, par l’intermédiaire d’un serveur supplémentaire, la création d’un système de notifications et l’envoi de messages du client au serveur… et vice versa !
Pour réaliser ce tutoriel, nous allons partir d’une version de Symfony 3.4, avec la librairie Ratchet. Ratchet est une librairie PHP qui facilite la mise en place d’applications utilisant des WebSockets et qui va nous faire gagner pas mal de temps (cf : l’article de Raphaël Gonçalves).
À noter : j’aurais préféré utiliser Symfony 4 (fraîchement sorti), mais, à l’heure où j’écris ce tutoriel,
la librairie cboden/ratchet
n’est pas encore compatible avec Symfony 4
(ça ne saurait tarder).
$ symfony new afsy-websocket-tutorial 3.4
$ cd afsy-websocket-tutorial
$ composer require cboden/ratchet
Pour créer notre serveur de chat, nous n'aurons besoin que d’une seule chose :
une classe qui implémente l’interface Ratchet\MessageComponentInterface
.
Nous créons donc le fichier src/AppBundle/Server/Chat.php
avec le contenu suivant :
// src/AppBundle/Server/Chat.php
namespace AppBundle\Server;
use Ratchet\ConnectionInterface;
use Ratchet\MessageComponentInterface;
class Chat implements MessageComponentInterface
{
private $clients;
public function __construct()
{
$this->clients = new \SplObjectStorage();
}
public function onOpen(ConnectionInterface $conn)
{
$this->clients->attach($conn);
$conn->send(sprintf('New connection: Hello #%d', $conn->resourceId));
}
public function onClose(ConnectionInterface $closedConnection)
{
$this->clients->detach($closedConnection);
echo sprintf('Connection #%d has disconnected\n', $closedConnection->resourceId);
}
public function onError(ConnectionInterface $conn, \Exception $e)
{
$conn->send('An error has occurred: '.$e->getMessage());
$conn->close();
}
public function onMessage(ConnectionInterface $from, $message)
{
$totalClients = count($this->clients) - 1;
echo vsprintf(
'Connection #%1$d sending message "%2$s" to %3$d other connection%4$s'."\n", [
$from->resourceId,
$message,
$totalClients,
$totalClients === 1 ? '' : 's'
]);
foreach ($this->clients as $client) {
if ($from !== $client) {
$client->send($message);
}
}
}
}
Et voilà, c’est tout… Ou presque. Il faut maintenant le lancer. Pour cela, deux possibilités s’offrent à nous :
Évidemment, nous allons créer une commande Symfony : ça semble plus cohérent et, accessoirement, ça nous permet de rester dans le sujet.
// src/AppBundle/Command/ChatServerCommand.php
namespace AppBundle\Command;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Ratchet\Server\IoServer;
use AppBundle\Server\Chat;
class ChatServerCommand extends ContainerAwareCommand
{
protected function configure()
{
$this
->setName('afsy:app:chat-server')
->setDescription('Start chat server');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$server = IoServer::factory(
new Chat(),
8080,
'127.0.0.1'
);
$server->run();
}
}
Nous pourrions spécifier le port 8080
en paramètre de l’application,
mais nous préférons rester sur l’essentiel.
Une fois la classe et la commande créées, il ne reste plus qu’à lancer la commande et à la tester :
$ php bin/console afsy:app:chat-server
Et là, il ne se passe (presque) rien ! Tout est normal. Pour tester que le script fonctionne bien, il faut démarrer une connexion à ce serveur (depuis une autre fenêtre de terminal) :
$ telnet 127.0.0.1 8080
# Et vous devriez avoir un message qui ressemble à :
# Trying 127.0.0.1...
# Connected to localhost.
# Escape character is '^]'.
# New connection: Hello #545
Vous pouvez évidemment ouvrir plusieurs fenêtres comme celle-ci et tester que les messages envoyés par une des fenêtres sont bien envoyés aux autres fenêtres.
Maintenant que nous avons vu que le serveur de chat est opérationnel,
nous pouvons créer notre page de chat.
Pour cela, nous avons seulement besoin de modifier la page par défaut de Symfony pour y ajouter notre code.
Nous commençons tout d’abord par modifier le contrôleur pour remplacer le contenu de l’action
indexAction()
par :
// src/AppBundle/Controller/DefaultController.php
return $this->render('default/index.html.twig', [
'ws_url' => 'localhost:8080',
]);
Ainsi que le contenu de la vue associée par le contenu suivant :
{# app/Resources/views/default/index.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<div class="container">
<h1>AFSY - WebSockets and Symfony</h1>
<div id="ws-content-receiver">
Connecting...
</div>
</div>
{% endblock %}
{% block javascripts %}
<script type="text/javascript">
var wsUrl = '{{ ws_url }}';
</script>
<script type="text/javascript" src="{{ asset('bundles/app/js/sf-websocket.js') }}"></script>
{% endblock %}
Nous sommes maintenant fin prêts à mettre en place le JavaScript dans le fichier js/sf-websocket.js
.
Petit conseil avant de passer à la suite : il est grandement conseillé de mettre en place
ESLint sur le projet
(afin d’éviter de nombreuses erreurs et d’avoir une base de code uniforme).
Avant de mettre en place le JavaScript, il est important de modifier la commande du serveur.
Celle que nous avons créée ne supporte que les appels de la ligne de commande et il faut modifier
le premier paramètre du constructeur de IoServer::factory()
pour pouvoir ajouter un support en HTTP et en WebSocket.
Notre serveur se lancera donc de la manière suivante à partir de maintenant :
// src/AppBundle/Command/ChatServerCommand.php
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
$server = IoServer::factory(
new HttpServer(new WsServer(new Chat())),
8080,
'127.0.0.1'
);
Nous pouvons maintenant modifier notre fichier JavaScript pour y ajouter le contenu suivant :
// src/AppBundle/Resources/public/js/sf-websocket.js
/* globals wsUrl: true */
(function () {
'use strict';
var _receiver = document.getElementById('ws-content-receiver');
var ws = new WebSocket('ws://' + wsUrl);
ws.onopen = function () {
ws.send('Hello');
_receiver.innerHTML = 'Connected !';
};
ws.onmessage = function (event) {
_receiver.innerHTML = event.data;
};
ws.onclose = function () {
_receiver.innerHTML = 'Connection closed';
};
ws.onerror = function () {
_receiver.innerHTML = 'An error occured!';
};
})();
Pour faire notre premier test, nous avons besoin de :
php bin/console assets:install --symlink
;
php bin/console server:start
;
php bin/console afsy:app:chat-server
;
Il ne nous reste plus qu’à consulter la page que nous venons de modifier pour voir le résultat suivant :
Ça y est, nous avons une connexion en WebSocket entre notre JavaScript et notre application Symfony.
Maintenant que le plus dur est fait, nous allons créer un chat et vérifier que tout communique bien ensemble.
Pour ajouter la notion d’utilisateur et de canal, nous modifions notre serveur de chat pour lui ajouter trois propriétés :
users
qui sera la liste des utilisateurs :
une liste associative avec la clé qui correspond à l’id de la connexion
et dont la valeur est un tableau associatif contenant l’object de connexion, le pseudo de l’utilisateur
et la liste des canaux auxquels il est abonné
botName
qui sera notre utilisateur par défautdefaultChannel
qui sera le nom du canal par défaut// src/AppBundle/Server/Chat.php
private $users = [];
private $botName = 'ChatBot';
private $defaultChannel = 'general';
Une fois nos variables ajoutées, on modifie la méthode onOpen()
pour ajouter la connexion à la liste des utilisateurs :
// src/AppBundle/Server/Chat.php
$this->users[$conn->resourceId] = [
'connection' => $conn,
'user' => '',
'channels' => []
];
Bien évidemment, on modifie aussi la méthode onClose()
pour supprimer l’utilisateur de la liste lors de sa déconnexion :
// src/AppBundle/Server/Chat.php
unset($this->users[$closedConnection->resourceId]);
Maintenant que nous avons un système qui peut gérer des utilisateurs et des canaux, nous pouvons modifier la communication entre le front et le serveur de chat pour tout passer en JSON.
Nous avons utilisé des messages basiques au début, mais pour avoir un chat digne de ce nom, le JSON semble une des solutions les plus simples et efficaces à mettre en place.
Pour commencer, nous devons modifier la réception des messages dans la méthode Chat::onMessage()
pour gérer plusieurs actions :
subscribe
: pour lier un utilisateur à un canal ;unsubscribe
: pour supprimer le lien entre l’utilisateur et le canal ;message
: pour que l’utilisateur puisse poster un message.// src/AppBundle/Server/Chat.php
public function onMessage(ConnectionInterface $conn, $message)
{
$messageData = json_decode($message);
if ($messageData === null) {
return false;
}
$action = $messageData->action ?? 'unknown';
$channel = $messageData->channel ?? $this->defaultChannel;
$user = $messageData->user ?? $this->botName;
$message = $messageData->message ?? '';
switch ($action) {
case 'subscribe':
$this->subscribeToChannel($conn, $channel, $user);
return true;
case 'unsubscribe':
$this->unsubscribeFromChannel($conn, $channel, $user);
return true;
case 'message':
return $this->sendMessageToChannel($conn, $channel, $user, $message);
default:
echo sprintf('Action "%s" is not supported yet!', $action);
break;
}
return false;
}
Comme vous pouvez le voir, ces actions auront, pour plus de lisibilité et de maintenabilité, chacune leur propre méthode :
// src/AppBundle/Server/Chat.php
private function subscribeToChannel(ConnectionInterface $conn, $channel, $user)
{
$this->users[$conn->resourceId]['channels'][$channel] = $channel;
$this->sendMessageToChannel(
$conn,
$channel,
$this->botName,
$user.' joined #'.$channel
);
}
// src/AppBundle/Server/Chat.php
private function unsubscribeFromChannel(ConnectionInterface $conn, $channel, $user)
{
if (array_key_exists($channel, $this->users[$conn->resourceId]['channels'])) {
unset($this->users[$conn->resourceId]['channels']);
}
$this->sendMessageToChannel(
$conn,
$channel,
$this->botName,
$user.' left #'.$channel
);
}
// src/AppBundle/Server/Chat.php
private function sendMessageToChannel(ConnectionInterface $conn, $channel, $user, $message)
{
if (!isset($this->users[$conn->resourceId]['channels'][$channel])) {
return false;
}
foreach ($this->users as $connectionId => $userConnection) {
if (array_key_exists($channel, $userConnection['channels'])) {
$userConnection['connection']->send(json_encode([
'action' => 'message',
'channel' => $channel,
'user' => $user,
'message' => $message
]));
}
}
return true;
}
Voilà notre serveur de chat peut maintenant recevoir et gérer différents utilisateurs, canaux et types d’actions.
Continuons avec la mise à jour du front pour qu'il puisse envoyer des messages dignes de ce nom au serveur. Pour cela, nous devons modifier le template pour y ajouter un formulaire d'envoi de message :
{# app/Resources/views/default/index.html.twig #}
<div id="ws-content-receiver"></div>
<input type="text" id="ws-content-to-send" />
<button id="ws-send-content">Send</button>
Le block #ws-content-receiver
a été vidé
pour qu’il soit entièrement géré par JavaScript.
Vous noterez que le formulaire est très basique et qu’il ne contient même pas la balise
<form>
, parce que ça n’est pas vraiment un « vrai » formulaire.
Une fois cette modification faite, nous revenons sur notre fichier JavaScript pour y ajouter
plusieurs choses :
Dorénavant, nous devons envoyer des messages au serveur au format JSON et nous devons donc adapter les méthodes du WebSocket pour prendre en compte cette modification. Au passage, nous ajoutons (comme nous l’avons fait côté serveur) un canal et un nom de bot par défaut.
// src/AppBundle/Resources/public/js/sf-websocket.js
var defaultChannel = 'general';
var botName = 'ChatBot';
var addMessageToChannel = function(message) {
_receiver.innerHTML += '<div class="message">' + message + '</div>';
};
var botMessageToGeneral = function (message) {
return addMessageToChannel(JSON.stringify({
action: 'message',
channel: defaultChannel,
user: botName,
message: message
}));
};
ws.onopen = function () {
ws.send(JSON.stringify({
action: 'subscribe',
channel: defaultChannel,
user: userName
}));
};
ws.onmessage = function (event) {
addMessageToChannel(event.data);
};
ws.onclose = function () {
botMessageToGeneral('Connection closed');
};
ws.onerror = function () {
botMessageToGeneral('An error occured!');
};
Il nous faut maintenant demander à l’utilisateur son identifiant et nous allons le faire de manière très simpliste :
// src/AppBundle/Resources/public/js/sf-websocket.js
var userName = prompt('Hi! I need your name for the Chat please :)');
Et c’est tout.
Dans une « vraie » application, il faudrait vérifier si un autre utilisateur n’a pas le même identifiant, mais pour ce tutoriel, restons simples.
Pour envoyer un message de l’utilisateur, il nous faut deux~trois choses :
// src/AppBundle/Resources/public/js/sf-websocket.js
var _textInput = document.getElementById('ws-content-to-send');
var _textSender = document.getElementById('ws-send-content');
var enterKeyCode = 13;
var sendTextInputContent = function () {
// Get text input content
var content = _textInput.value;
// Send it to WS
ws.send(JSON.stringify({
action: 'message',
user: userName,
message: content,
channel: 'general'
}));
// Reset input
_textInput.value = '';
};
_textSender.onclick = sendTextInputContent;
_textInput.onkeyup = function(e) {
// Check for Enter key
if (e.keyCode === enterKeyCode) {
sendTextInputContent();
}
};
Nous pouvons maintenant relancer notre serveur de chat et retourner sur notre page pour vérifier que tout fonctionne bien.
Le navigateur nous demande bien notre identité au chargement de la page.
Et nous avons bien une communication établie en JSON. Vous pouvez normalement envoyer des messages qui s’afficheront dans l’ensemble des fenêtres connectées à l’application.
Et voilà, c’est terminé pour aujourd’hui ! Alors oui, l’interface est minimaliste et tout reste à faire, mais le but de cet article était de vous montrer comment mettre en place des WebSockets dans un projet Symfony. Pour aller plus loin, j’ai créé un projet Github avec le code commenté (contrairement à celui publié dans cet article).
J’espère que cet article vous aura plu, 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 !