Aujourd’hui le temps réel est de plus en plus présent sur le web : messagerie instantanée, notifications, édition collaborative, … Ces technologies permettent de nouvelles fonctionnalités et répondent à de nouveaux usages.Les WebSockets sont arrivés pour pallier aux contraintes du polling en rendant possible les échanges bidirectionnels entre un client et un serveur. Ils sont fréquemment implémentés grâce à l’écosystème NodeJS et JavaScript. Savez-vous qu’il est possible d’intégrer des WebSockets simplement en utilisant PHP ?

WebSocket ?

WebSocket est un protocole qui a été normalisé dans la RFC 6455 en 2011. Il permet des échanges en temps réel entre un client et un serveur. La particularité de son fonctionnement se situe au niveau du serveur puisqu’il n’envoie pas uniquement des réponses mais peut envoyer un message spontanément au client.

Ce protocole est aussi full-duplex : les données peuvent transiter simultanément dans les deux sens. Il permet ainsi une forte interactivité ce qui en fait sa principale force.

Pour mettre en place un serveur WebSocket, il faut créer un programme qui va être exécuté directement depuis le serveur (à travers un service par exemple). Il ne sera donc pas appelé via un navigateur comme on peut en avoir l’habitude avec PHP.

Si, vous écrivez ce programme en JavaScript alors que vous utilisez déjà PHP par ailleurs, il faudra embarquer 2 technologies différentes coté serveur. C’est un choix impactant pour le projet. Une fois implémenté, il faudra faire vivre chacune des parties et donc assurer la stabilité du code, faire les mises à jour des logiciels sur le serveur ou encore gérer le monitoring. Pour être à l’aise, il convient donc d’avoir une bonne connaissance des outils que vous utilisez. Plus le nombre d’outil est important, plus c’est complexe à maintenir. C’est sur ce point qu’on peut se poser la question « Pourrait-on avoir un serveur de WebSocket directement en PHP si on utilise déjà PHP par ailleurs ? ». Le simple fait de se poser la question permet déjà de faire un choix éclairé et ne pas se lancer tête baissée dans la première solution qui s’offre à nous !

La plupart des langages possèdent au moins une implémentation de ce protocole, il faut prendre le temps de les découvrir, ensuite la solution la plus appropriée s’impose comme un compromis entre les compétences de l’équipe, la durée de vie de l’application et le type de fonctionnalités à intégrer.

Par où commencer ?

Nous allons voir comment utiliser les WebSockets en PHP. Savoir utiliser Composer est fortement recommandé pour gérer vos dépendances. Cet outil s’est imposé comme un standard aujourd’hui, il convient de connaître son fonctionnement général.

Il existe une grande quantité de packages et il est parfois difficile de faire le tri. Voici deux packages de référence que j’ai l’habitude d’utiliser : Ratchet (cboden/ratchet + ratchet/pawl) et hoa/websocket. Ratchet est le plus utilisé aujourd’hui et il est correctement maintenu. Ma préférence va vers Hoa pour la simplicité du code mais ils manquent cruellement de contributeur pour continuer de faire évoluer le projet.

Ce sont deux implémentations solides et bien documentées qu’il est intéressant de découvrir pour bien comprendre le fonctionnement du protocole.

Une messagerie instantanée avec Ratchet

Pour vous montrer la logique globale à implémenter, je vais m’appuyer sur un système de chat. Cet exemple, volontairement simplifié contiendra les fonctionnalités suivantes :

  • Le client se connecte sur le serveur, il est ajouté à la liste des utilisateurs ;
  • Il peut commencer à parler ;
  • Les messages sont échangés entre les utilisateurs ;
  • Il peut définir son nom qui sera transmis avec le message.

Je vais utiliser Ratchet pour l’exemple mais l’approche est similaire avec d’autres outils. Sous le capot, Ratchet utilise l’écosystème ReactPHP et la programmation asynchrone ce qui permet une utilisation très efficace des transferts réseaux. De plus ReactPHP est basé sur le pattern Event Loop qui est utilisé pour propager les messages à travers le code de l’application. À chaque évènement sur une connexion (ouverture, fermeture, réception d’un message), une partie du code est exécuté.

Ce lien de parenté entre les deux outils est intéressant car il simplifie l’utilisation d’autres fonctionnalités de ReactPHP comme les flux de lecture non bloquant.

Création du serveur

Avant de gérer des interactions en temps réel, il convient de créer le serveur. La version actuelle de Ratchet fonctionne avec PHP 7.4 même si elle est utilisable à partir de PHP 5.4.2. Pour l’installer avec Composer il suffit de lancer la commande suivante :

composer require cboden/ratchet

Créons ensuite server.php, un fichier PHP pour déclarer le serveur en n’oubliant pas de charger les dépendances. Ce serveur sera accessible localement sur le port 8080 :

$app = new Ratchet\App('127.0.0.1', 8080);
$app->route('/echo', new Ratchet\Server\EchoServer, ['*']);
$app->run();

Vous pouvez lancer le serveur en exécutant le script PHP dans votre terminal, il est disponible sur le réseau à l’adresse “ws://127.0.0.1:8080/”. Il faut ensuite définir son comportement. Au sein d’un même serveur, il est possible de traiter de différentes façons les messages grâce au système de route intégré.

L’objet EchoServer intégré à Ratchet renvoie au client tout les messages transmis, il porte bien son nom. Il permet d’avoir une version opérationnelle du serveur avec lequel les premiers échanges sont possibles.

Implémentation du client

Maintenant que le serveur est opérationnel, nous allons pouvoir créer le client. Pour cette partie, une autre librairie est à installer avec Composer :

composer require ratchet/pawl

La séparation du client et du serveur en deux packages distincts peut paraitre complexe mais en réalité vous aurez rarement un client et un serveur au sein de la même application. Vous pouvez donc intégrer uniquement la partie qui vous intéresse et ainsi limiter la quantité de dépendances et faciliter la maintenance.

Créons maintenant client.php, un second fichier pour déclarer le client. Il doit se connecter sur l’URL “/echo” du serveur qui écoute sur le port 8080 :

use Ratchet\RFC6455\Messaging\MessageInterface;

$loop = React\EventLoop\Factory::create();

$connector = new Ratchet\Client\Connector($loop);
$connection = $connector('ws://127.0.0.1:8080/echo')->then(
    function (Ratchet\Client\WebSocket $conn) {
        $conn->on('message', function (MessageInterface $msg) use ($conn) {
            echo "{$msg}\n";
            $conn->close();
        });

        $conn->send('Hello world !');
    }, function (Throwable $e) {
        echo "Could not connect: {$e->getMessage()}\n";
    }
);

$loop->run();

Pour que le client fonctionne, il doit être attaché à une boucle d’évènement que nous créons dans la variable $loop. Toutes les interactions sur les flux réseaux auront lieu dans cette boucle.
La dernière ligne du script est indispensable puisqu’elle permet de lancer cette boucle. Elle tournera en attendant de recevoir des évènements jusqu’à ce que l’utilisateur décide de l’arrêter.

Ratchet\Client\Connector est initialisé et, comme il s’agit d’un objet invocable, il peut être appelé comme une fonction. Cet appel renvoie un objet React\Promise\PromiseInterface que nous configurons avec deux fonctions anonymes :

  • La première sera exécutée en cas de connexion réussie ;
  • La seconde permettra de gérer les différents cas d’erreur.

On observe dans la première fonction le début d’implémentation de la logique client. On demande d’afficher le message reçu dans la console lorsqu’un évènement de type message survient. Pour le moment, dès le premier message reçu du serveur, la connexion est terminée et le client s’arrête.

Juste en dessous, un message est envoyé au serveur grâce à la méthode send. Le contenu des messages est une chaîne de caractère. Il est donc possible d’utiliser un langage structuré comme du JSON pour aller plus loin dans les échanges (définir l’auteur du message, embarquer des informations complémentaires…). Certains ont même été encore plus loin en spécifiant un protocole d’échange à travers les WebSocket : Web Application Messaging Protocol (WAMP).

Le client va donc, une fois la connexion ouverte, envoyer le message “Hello world !” et recevoir ce même message en retour car le serveur renvoie tout ce qu’il a reçu à l’expéditeur.

Pour faire un parallèle avec le protocole, voici ce qui se passe lors de l’ouverture d’une connexion. Le client envoie une requête HTTP demandant le changement de protocole (passage de HTTP à WebSocket). Une réponse du serveur permet d’authentifier et démarrer la nouvelle connexion. À partir de cet instant, les échanges ne se font plus sous forme de requêtes HTTP mais à travers des messages encodés dans des frames (un paquet de bits structurés). Ratchet gère entièrement la lecture et l’écriture des frames ce qui permet de ce concentrer sur le texte à envoyer.

Implémentation de la logique serveur

Pour le moment le serveur est seulement capable de faire un écho des messages. Nous allons maintenant voir comment créer une implémentation spécifique.
Pour cela Ratchet nous met à disposition l’interface Ratchet\MessageComponentInterface qui nous demande de définir quatre méthodes: onOpen, onMessage, onClose, onError. Ces méthodes renvoient directement aux événements qui seront déclenchés par la boucle d’événement interne.

Habituellement, PHP est utilisé en mode requête/réponse. À chaque requête, un script est invoqué, il calcul une réponse et elle est renvoyée. Une nouvelle requête signifie une nouvelle invocation du script. Dans un sens, le langage peut être considéré stateless dans ce contexte. Cependant, des outils externes permettent de maintenir un état entre les différentes requêtes: session, base de données…

Ici, le paradigme est différent. Le script server.php va s’exécuter en continue et va donc gérer plusieurs “requêtes” sans s’arrêter. C’est intéressant parce que la mémoire utilisée au sein du script peut contenir un état (liste d’utilisateurs actifs par exemple) mais c’est à double tranchant. Il faut faire très attention à libérer les ressources utilisées et à limiter les données stockées dans la mémoire interne pour rester efficace. Rien n’empêche d’utiliser un système tiers (base de données, système de cache, …) au sein d’un serveur de WebSocket pour faciliter la gestion des données.

Dans le cadre d’un système de messagerie instantanée, le serveur doit avoir “conscience” de tout les client connectés pour communiquer avec eux. Aussi, quand un client envoi un message, il faut envoyer ce même message à tout les autres. Notre serveur va donc maintenir une liste de clients actifs pour agir en conséquence.
Nous allons implémenter ce nouveau serveur dans une classe (src/ChatServer.php) que nous allons connecter sur une autre route :

namespace CHStudio\IPC\WebSocket;

class ChatServer implements Ratchet\MessageComponentInterface 
{
    private $clients;

    public function __construct() 
    {
        $this->clients = new \SplObjectStorage;
    }

    public function onOpen(Ratchet\ConnectionInterface $conn) 
    {
        $this->clients->attach($conn);
    }

    public function onMessage(Ratchet\ConnectionInterface $from, $message) 
    {
        foreach ($this->clients as $client) {
            if ($client !== $from) {
                $client->send($message);
            }
        }
    }

    public function onClose(Ratchet\ConnectionInterface $conn) 
    {
        $this->clients->detach($conn);
    }

    public function onError(Ratchet\ConnectionInterface $conn, \Exception $e) 
    {
        $conn->close();
        echo “Error: {$e->getMessage()}\n“;
    }
}

Détaillons le contenu de ce nouvel objet. On peut noter la présence d’une propriété, $clients, qui est initialisée à la construction en tant que SplObjectStorage. Cette structure de donnée de la “Standard PHP Library” est intéressante parce que, contrairement à un tableau simple, elle peut utiliser des objets comme clé de stockage.

Les méthodes onOpen() et onClose(), vont s’exécuter lorsqu’un client se connecte ou se déconnecte. C’est ici qu’est gérée la liste des clients actifs. Un appel aux méthodes attach() et detach() de SplObjectStorage nous permet de maintenir une liste de client présent fiable.

Ensuite la méthode onMessage() est la plus centrale car elle s’exécute lorsqu’un message arrive. C’est ici qu’est définit la logique d’échange entre les différents clients. Une boucle nous permet de parcourir les connexions actives et d’envoyer le message à tout les clients sauf celui qui vient de l’émettre.

La dernière méthode, onError(), permet de gérer les différentes erreurs. Comme le serveur s’exécute en continue il ne doit pas s’arrêter dès qu’une exception est lancée. Ce dernier écouteur est donc très important, parce qu’il permet de juger du niveau de l’erreur et décider si tout doit être arrêté ou non.

Maintenant que cet objet est défini, il faut configurer le serveur pour qu’il l’utilise. Modifions le script server.php en conséquence.

$app = new Ratchet\App('127.0.0.1', 8080);
$app->route('/echo', new Ratchet\Server\EchoServer, [‘*']);
$app->route('/chat', new CHStudio\IPC\WebSocket\ChatServer, ['*']);
$app->run();

Une nouvelle route est donc ajoutée : “/chat”. Notre nouvel objet doit bien sur être chargé pour être utilisé (en utilisant Composer, un autoload ou un simple include).

Envoi des messages par le client

Le serveur est maintenant opérationnel mais le client n’a pas encore la capacité d’envoyer des messages écrits par l’utilisateur. Il faut permettre d’entrer du texte au clavier sans pour autant bloquer l’arrivée des messages, c’est à dire que le script continue de tourner tant que rien n’a été saisi au clavier.

La logique de traitement non bloquant et asynchrone est au cœur de ReactPHP. Comme nous utilisons cet écosystème, nous avons à notre disposition un objet adapté, capable de lire un flux de manière asynchrone : React\Stream\ReadableResourceStream.

En PHP, la plupart des fonctions de manipulation de fichier ou de traitement réseau sont bloquantes. C’est à dire que le code attend une réponse avant de poursuivre son exécution. Cette attente est problématique dans notre cas pour deux raisons :

  • Très inefficace, le programme ne fait rien pendant cette attente ;
  • Interdit les interactions concurrentes.

C’est ce dernier point qui est le plus important. Pour saisir au clavier tout en continuant d’afficher les messages provenant du serveur, le programme ne doit pas être mis en attente. ReactPHP utilise les fonctions natives de PHP, principalement stream_select() pour gérer cette concurrence.

Toutes les sources d’informations doivent être initialisées sous la forme de flux (fichier, socket) et connectées à la même instance d’Event Loop. Ensuite, à chaque itération de la boucle, les flux sont analysés : s’il y a une information à transmettre, elle est lue et interprétée, sinon on passe au suivant. Le traitement de l’information peut être bloquant mais il s’exécute principalement pendant les temps d’attente.

Voici comment modifier le client pour lire les entrées au clavier et les transmettre au serveur :

use Ratchet\Client\WebSocket;
use Ratchet\RFC6455\Messaging\MessageInterface;

$loop = React\EventLoop\Factory::create();
$stdin = new React\Stream\ReadableResourceStream(STDIN, $loop);

$connector = new Ratchet\Client\Connector($loop);
$connection = $connector('ws://127.0.0.1:8080/chat')->then(
    function (WebSocket $conn) use ($stdin) {
        $conn->on('message', function (MessageInterface $msg) {
            echo "{$msg}\n";
        });

        $stdin->on('data', function (string $data) use ($conn) {
            $conn->send(rtrim($data, "\n"));
        });
    }, function (Throwable $e) {
        echo "Could not connect: {$e->getMessage()}\n";
    }
);

$loop->run();

Il y a peu de différence par rapport au précédent fichier. La variable $stdin permet la lecture du flux d’entrée. Un écouteur est déclaré pour envoyer les données saisies à chaque fois que l’évènement “data” est déclenché sur $stdin.

Le client et le serveur sont maintenant capable de communiquer et, si plusieurs clients sont connectés en même temps, ils peuvent échanger des messages.

Enrichir le serveur pour personnaliser le client

Nous avons précédemment vu que le serveur maintient une liste de connexions actives en mémoire. Nous pouvons utiliser cette liste pour stocker, par exemple, le nom de l’utilisateur.

Pour cela, nous allons définir une commande sous forme de texte structuré que l’utilisateur pourra saisir et qui sera interprétée par le serveur : /name Username

Voici comment mettre à jour les méthodes onOpen() et onMessage() du fichier ChatServer.php :

public function onOpen(ConnectionInterface $conn)
{
    $this->clients->attach($conn, new \stdClass);
}

public function onMessage(ConnectionInterface $from, $message)
{
    if (0 === strpos($message, '/name')) {
        $this->clients[$from]->name = trim(substr($message, 5));
        $from->send(sprintf(
            '[server] your name is now: %s', 
            $this->clients[$from]->name
        ));
        return;
    }

    if (isset($this->clients[$from]->name)) {
        $message = sprintf('%s: %s', $this->clients[$from]->name, $message);
    }

    foreach ($this->clients as $client) {
        if ($client !== $from) {
            $client->send($message);
        }
    }
}

À l’arrivée d’un client, un objet vide, stdClass est stocké dans la liste et lié à la connexion. C’est l’avantage de passer par SplObjectStorage, il permet de lier deux objets ensembles. Cet objet vide sera donc présent pour toute la durée de la connexion du client et comme il est lié à cette connexion nous pourrons y accéder facilement.

Ensuite, à chaque message reçu par le serveur, il faut vérifier le contenu. S’il commence par “/name”, la commande saisie permettra d’extraire le nouveau nom d’utilisateur. Ce nom est stocké dans la liste des clients à l’intérieur de l’objet vide précédemment initialisé.

Comme ce message est une commande, il ne doit pas être envoyé aux autres clients connectés. Le client ayant transmis ce message est informé en retour que le nom a bien été pris en compte et la méthode est stoppée grâce à return.

Enfin, dans le cas d’un message standard, le nom de l’utilisateur est concaténé au message avant d’être envoyé aux autres clients. Ainsi chaque client peut savoir qui vient d’écrire.

On voit donc qu’il est possible d’enrichir l’expérience en stockant des informations complémentaires à propos d’une connexion. Il n’est ensuite pas très complexe d’interdire d’envoyer des messages si le nom n’est pas encore défini ou de prévenir les clients connectés lorsqu’un nouveau rejoint ou quitte la session.

Conclusion

Vous avez pu voir qu’il est possible de faire des WebSocket en PHP grâce à des librairies solides et accessibles. L’avantage est de pouvoir ajouter ce protocole au sein d’une application existante et même partager quelques bribes de logiques (modèle, règles métiers…).

L’exécution du serveur, dans un service autonome est un peu plus complexe que le modèle Requête/Réponse habituel. Les outils comme Supervisor ou SystemD s’utilisent aussi avec PHP, ils vous aideront à avoir un environnement fiable et maîtrisé.

Vous pouvez retrouver le code du serveur et du client décrit dans cet article sur GitHub : https://github.com/shulard/ipc-websocket-sample.

Pour aller plus loin

En parallèle de ce protocole, le W3C a aussi spécifié une API implémentée dans les navigateurs en JavaScript. Elle est très bien supportée par les navigateurs aujourd’hui. Cette API permet de définir un client au sein d’une page web pour interagir avec un serveur.
Dans le cadre d’une application web, construire un serveur en PHP et un client dans le navigateur est une solution efficace.

L’utilisation des WebSockets est pertinente uniquement dans le cas ou il y a des échanges dans les deux sens. Sinon il existe des solutions moins complexes et plus adaptées pour les applications web :

  • Du client vers le serveur : une API et des appels HTTP suffisent pour répondre à la plupart des cas ;
  • Du serveur vers le client : pensez à utiliser les Server-Sent Events qui sont fait pour ça et très bien supportés dans les navigateurs.

Cet article a été rédigé pour le PHP Magazin du mois d’Août dans lequel il est traduit en allemand.

https://kiosk.entwickler.de/php-magazin/php-magazin-6-2020/vom-protokoll-zur-loesung-in-purem-php/