Close search
Hoa

Hack book de Hoa\Websocket

Le protocole WebSocket permet une communication bidirectionnelle et full-duplex entre un client et un serveur. La bibliothèque Hoa\Websocket permet de créer des serveurs et des clients WebSocket.

Table des matières

  1. Introduction
    1. Handshake et challenge
    2. Frame et opcode
    3. Historique
  2. Écrire un serveur
    1. Écouteurs
    2. Échanges de messages
    3. Diffusions de messages
    4. Fermeture
  3. Message
    1. Fragmentation
    2. Encodage
    3. Binaire
  4. Écrire un client
    1. Démarrer une connexion avec le serveur
    2. Échanges et diffusions de messages
    3. Précision sur le handshake et l'hôte
  5. Nœud personnalisé
  6. Conclusion

Introduction

Le protocole WebSocket est standardisé dans la RFC6455. Il permet à un client et à un serveur de communiquer ensemble. Cette communication est bidirectionnelle, cela signifie que le client peut envoyer des messages au serveur, et inversement. Le serveur n'envoie pas uniquement des réponses il peut envoyer un message spontanément. Cela change des habitudes du Web et de son protocole HTTP. Le protocole WebSocket est également full-duplex, c'est à dire que les données sont échangées simultanément dans les deux sens : ce n'est pas parce que le serveur a envoyé une donnée qui est en cours d'acheminement que le client ne peut pas envoyer de données à son tour. Le protocole WebSocket permet alors une forte interactivité entre le client et le serveur. Le client sera très souvent un navigateur. Notons que le schéma URI (voir la RFC3986) du protocole WebSocket est ws://.

Nous pouvons nous demander quelles sont les différences entre WebSocket et EventSource. Ces deux solutions sont en fait fondamentalement différentes : WebSocket permet une communication bidirectionnelle et full-duplex, alors que EventSource est une technologie basée sur le protocole HTTP et ne propose qu'une communication unidirectionnelle. Pour cet usage, un serveur EventSource est plus léger, plus simple et conçu pour être robuste aux déconnexions (voir la bibliothèque Hoa\Eventsource).

Le protocole WebSocket commence par une phase de handshake afin de permettre, par la suite, les échanges de messages sous forme de frames.

Handshake et challenge

Pour démarrer une communication avec le protocole WebSocket, le client doit envoyer une requête HTTP au serveur en lui demandant de changer de protocole. Dans cette requête, le client insère un challenge, une sorte de petite énigme, que le serveur doit résoudre. S'il l'a résolu correctement, alors la communication démarrera.

Le fait de commencer par une requête HTTP n'est pas anodin. Cela permet au protocole WebSocket d'emprunter le même chemin que les requêtes HTTP, et ainsi, par exemple, traverser les proxys, les pare-feu etc. Cela facilite également le déploiement de ce protocole : pas besoin de lui réserver un port particulier, pas besoin d'avoir une configuration serveur particulière etc. Cela permet enfin d'utiliser une connexion sécurisée, à travers TLS. Dans ce cas, nous utilisons le schéma URI wss://.

Frame et opcode

Les messages qui sont échangés entre le client et le serveur ne se font pas verbatim. En réalité, le message est encapsulé dans une frame : un paquet de bits qui a une forme particulière. Dans ce cas, nous trouverons des informations concernant le type du message, sa taille, des codes de vérifications etc. Nous trouverons un schéma explicatif dans la section 5.2, Base Framing Protocol de la spécification du protocole pour les plus curieux.

Le type du message est appelé opcode. C'est l'information la plus importante. Nous retrouverons ce terme dans ce chapitre plusieurs fois. Des constantes pour chaque opcode existent dans la classe Hoa\Websocket\Connection afin de simplifier leur utilisation. C'est cette classe qui s'assure du support du protocole.

Historique

Il existe deux versions du protocole WebSocket dans la nature : la version standard et la version non-standard. La version standard est celle décrite dans la RFC6455. La dernière version non-standard porte le petit nom de draft-ietf-hybi-thewebsocketprotocol-00 (ou draft-hixie-thewebsocketprotocol-76), abrégé Hybi00. Cette version non-standard a plusieurs problèmes de sécurité importants mais elle est utilisée dans des langages comme Flash. Heureusement, elle disparaît de plus en plus et laisse la place à la RFC6455.

La bibliothèque Hoa\Websocket supporte ces deux versions. Elle permet à des clients supportant des versions différentes du protocole de communiquer quand même.

Écrire un serveur

La classe Hoa\Websocket\Server permet d'écrire un serveur manipulant le protocole WebSocket. Cette classe hérite de Hoa\Websocket\Connection. La communication s'effectue à travers un serveur de socket. Nous utiliserons la classe Hoa\Socket\Server (de la bibliothèque Hoa\Socket) pour remplir ce rôle.

Le protocole WebSocket fonctionne en TCP, ainsi nous allons démarrer un serveur WebSocket en local sur le port 8889 :

$server = new Hoa\Websocket\Server(
    new Hoa\Socket\Server('tcp://127.0.0.1:8889')
);

Toutefois, nous pouvons utiliser l'URI ws://127.0.0.1:8889 directement à la place de tcp://127.0.0.1:8889. Cela a un avantage lorsque nous utilisons wss:// pour une connexion sécurisée car Hoa\Websocket saura que la connexion devra être sécurisée et le fera à votre place. Vous n'aurez pas à manipuler TLS, activer le cryptage sur certaines connections etc. Ainsi :

$server = new Hoa\Websocket\Server(
    new Hoa\Socket\Server('ws://127.0.0.1:8889')
);

Maintenant, voyons comment interagir avec ce serveur.

Écouteurs

La classe Hoa\Websocket\Connection propose six écouteurs :

Pour les écouteurs message et binary-message, il n'y a qu'une seule donnée associée : message, qui contient sans surprise le message reçu.

Pour l'écouteur ping, nous trouvons aussi la donnée message. Notons que le pong se fait automatiquement avant de déclencher l'écouteur.

Pour l'écouteur error, nous trouvons la donnée exception qui contient une exception (pas nécessairement Hoa\Websocket\Exception\Exception, cela peut-être par exemple Hoa\Socket\Exception). L'écouteur est déclenché après que la connexion ait été fermée.

L'écouteur close a deux données associées : code pour le code et reason qui explique la raison de cette fermeture avec un message court. Nous trouverons les codes de fermetures standards sous forme de constantes CLOSE_* dans la classe Hoa\Websocket\Connection. Par exemple, Hoa\Websocket\Connection::CLOSE_NORMAL symbolise une fermeture de connexion normale, sans erreur, alors que Hoa\Websocket\Connection::CLOSE_MESSAGE_ERROR symbolise une fermeture de connexion suite à un message mal formé. Cet écouteur est déclenché après que la connexion ait été fermée.

Échanges de messages

Complétons notre exemple pour, dans l'écouteur message, renvoyer au client tous les messages qu'il nous envoie de façon à créer un écho. Pour cela, nous allons utiliser la méthode Hoa\Websocket\Connection::send. Une fois que notre écouteur est positionné, nous pouvons démarrer le serveur à l'aide de la méthode Hoa\Websocket\Connection::run. Ainsi :

$server->on('message', function (Hoa\Event\Bucket $bucket) {
    $data = $bucket->getData();

    echo 'message: ', $data['message'], "\n";
    $bucket->getSource()->send($data['message']);

    return;
});

$server->run();

Nous allons maintenant tester notre serveur en créant un client HTML très simple :

<input type="text" id="input" placeholder="Message…" />
<hr />
<pre id="output"></pre>

<script>
  var host   = 'ws://127.0.0.1:8889';
  var socket = null;
  var input  = document.getElementById('input');
  var output = document.getElementById('output');
  var print  = function (message) {
      var samp       = document.createElement('samp');
      samp.innerHTML = message + '\n';
      output.appendChild(samp);

      return;
  };

  input.addEventListener('keyup', function (evt) {
      if (13 === evt.keyCode) {
          var msg = input.value;

          if (!msg) {
              return;
          }

          try {
              socket.send(msg);
              input.value = '';
              input.focus();
          } catch (e) {
              console.log(e);
          }

          return;
      }
  });

  try {
      socket = new WebSocket(host);
      socket.onopen = function () {
          print('connection is opened');
          input.focus();

          return;
      };
      socket.onmessage = function (msg) {
          print(msg.data);

          return;
      };
      socket.onclose = function () {
          print('connection is closed');

          return;
      };
  } catch (e) {
      console.log(e);
  }
</script>

À la ligne 6, nous déclarons l'adresse du serveur WebSocket en utilisant le protocole ws. À la ligne 45, nous utilisons l'objet WebSocket, et nous lui attachons des écouteurs, fortement semblables à ceux de Hoa\Websocket\Connection !

Pour tester, il suffit de démarrer le serveur :

$ php Server.php

Puis, d'ouvrir le client avec son navigateur préféré. Chaque message envoyé au serveur nous revient à l'identique, nous avons bien un écho.

Diffusions de messages

Pour l'instant, le client parle avec le serveur et le serveur lui répond, mais ça ne reste qu'un dialogue. Le serveur a pourtant toutes les connexions en mémoire. Nous sommes donc capable de diffuser un message à tous les clients connectés. Pour cela, nous allons utiliser la méthode Hoa\Websocket\Connection::broadcast qui va envoyer un message à tous les autres clients connectés, ainsi :

$server->on('message', function (Hoa\Event\Bucket $bucket) {
    $data = $bucket->getData();

    echo 'message: ', $data['message'], "\n";
    $bucket->getSource()->broadcast($data['message']);

    return;
});

Et voilà ! C'est aussi simple que ça. Redémarrons le serveur, et ouvrons plusieurs clients. Chaque message envoyé sera diffusé à tous les autres ! Notre exemple est devenu un outil de messagerie instantannée.

Il faut comprendre que le serveur de socket Hoa\Socket\Server travaille avec des nœuds, c'est à dire un objet qui représente une connexion ouverte. Dans un écouteur, pour connaître le nœud courant qui a déclenché l'appel à cet écouteur, nous devons appeler la méthode Hoa\Websocket\Connection::getConnection pour obtenir le serveur de socket, puis Hoa\Socket\Server::getCurrentNode. Similairement, nous avons la méthode Hoa\Socket\Server::getNodes pour obtenir tous les nœuds. La méthode Hoa\Websocket\Connection::broadcast vient en réalité de la bibliothèque Hoa\Socket et cache cette complexité. Il est préférable d'utiliser cette méthode pour des raisons de performance et de compatibilité.

Fermeture

Pour fermer la connexion avec le client, nous utilisons la méthode Hoa\Websocket\Connection::close. Elle est très similaire à Hoa\Websocket\Connection::send. Ses arguments sont :

Par exemple, quand nous recevons le message I love you, nous fermerons la connexion en expliquant pourquoi, sinon nous faisons un simple écho du message :

$server->on('message', function (Hoa\Event\Bucket $bucket) {
    $data = $bucket->getData();

    if ('I love you' === $data['message']) {
        $bucket->getSource()->close(
            Hoa\Websocket\Connection::CLOSE_NORMAL,
            'Thank you but my heart is already taken, bye bye!'
        );

        return;
    }

    $bucket->getSource()->send($data['message']);

    return;
});

Nous pouvons modifier notre client pour qu'il nous affiche le code et la raison d'une fermeture :

      socket.onclose = function (e) {
          print(
              'connection is closed (' + e.code + ' ' +
              (e.reason || '—no reason—') + ')'
          );

          return;
      };

Il est préférable de toujours utiliser cette méthode pour fermer une connexion plutôt que de fermer directement la connexion TCP.

Message

Nous avons deux façons d'envoyer des messages : soit en un seul morceau si nous avons le message en entier, soit en plusieurs morceaux. Notre message peut aussi contenir autre chose que du texte, il peut contenir une donnée binaire. Dans ce cas, nous parlons de message binaire.

Fragmentation

Pour envoyer un message en un seul bloc, nous utilisons la méthode Hoa\Websocket\Connection::send comme nous l'avons vu dans les sections précédentes :

$server->on('message', function (Hoa\Event\Bucket $bucket) {
    $bucket->getSource()->send('foobar');

    return;
});

Cette méthode comporte en réalité quatre arguments :

Nous allons utiliser tous les arguments en essayant d'envoyer un message fragmenté.

Dans notre exemple, nous avons envoyé un message en entier, ce qui est le cas le plus courant. Si nous envoyons un très long message, nous utiliserons également cette même méthode. Toutefois, il peut arriver que nous ayons le message morceau après morceau et nous sommes alors incapable de l'envoyer en entier. Par exemple, si le message, de taille indéterminée, est lu sur un flux et que nous voulons ensuite l'envoyer au client, nous n'allons pas attendre d'avoir tout le message : nous allons envoyer chaque morceau directement au client. Dans ce cas, nous parlons de messages fragmentés.

Nous allons utiliser les deux opcodes suivants : OPCODE_TEXT_FRAME pour le premier fragment, puis OPCODE_CONTINUATION_FRAME pour tous les suivants. À chaque fois, nous allons préciser que le message n'est pas terminé à l'aide de l'argument fin qui sera à false, sauf pour le dernier fragment où fin sera à true.

L'utilisateur final derrière le client ne recevra pas des messages fragmentés, mais le message en entier une fois que le dernier fragment aura été reçu. Côté serveur, cela nous évite de surcharger la mémoire avec des données « en transit » et aussi de surcharger le réseau avec un gros message. Nous envoyons les données dès que nous les avons et c'est le client qui s'occupe de reconstituer le message. Le serveur opère de la même façon lorsqu'il reçoit un message fragmenté. Entre deux fragments, le serveur peut aussi traiter d'autres tâches. Il est donc plus intéressant d'utiliser les fragments plutôt que de temporiser le message.

Passons à un exemple. Nous allons envoyer le message foobarbaz fragmenté en trois parties. Nous pouvons imaginer que nous lisons ces données sur une socket par exemple, et que les données viennent au fur et à mesure. Ainsi :

$server->on('message', function (Hoa\Event\Bucket $bucket) {
    $self = $bucket->getSource();

    $self->send(
        'foo',
        null,
        Hoa\Websocket\Connection::OPCODE_TEXT_FRAME,
        false // not the end…
    );
    echo 'sent foo', "\n";
    sleep(1);

    $self->send(
        'bar',
        null,
        Hoa\Websocket\Connection::OPCODE_CONTINUATION_FRAME,
        false // not the end…
    );
    echo 'sent bar', "\n";
    sleep(1);

    $self->send(
        'baz',
        null,
        Hoa\Websocket\Connection::OPCODE_CONTINUATION_FRAME,
        true // the end!
    );
    echo 'sent baz, over', "\n";

    return;
});

Les instructions sleep permettent d'émuler une latence réseau ou quelque chose du genre. À chaque appel de la méthode send, les données sont effectivement envoyées au client, ce n'est pas un tampon côté serveur.

Encodage

Tous les messages échangés doivent être au format UTF-8 (voir la RFC3629). Si les messages provenant du client ne sont pas conformes, Hoa\Websocket\Connection fermera la connexion de façon appropriée avec le code Hoa\Websocket\Connection::CLOSE_MESSAGE_ERROR, nous n'avons rien à faire de spécial. Par conséquent, tous les messages reçus dans nos écouteurs sont au bon encodage.

En revanche, Hoa\Websocket\Connection vérifie que les messages à destination du client sont dans le bon encodage. Si l'encodage n'est pas approprié, alors une exception Hoa\Websocket\Exception\InvalidMessage sera levée, ce qui fermera la connexion et déclenchera l'écouteur error si elle n'est pas capturée à temps.

Binaire

Il est également possible d'envoyer des données binaires, bien plus compactes que des données textuelles. Nous parlons alors de messages binaires. Nous allons toujours utiliser la méthode Hoa\Websocket\Connection::send mais avec l'opcode OPCODE_BINARY_FRAME. Cela n'a de sens que dans l'écouteur binary-message, c'est à dire dans un « échange binaire » entre le client et le serveur. Nous pouvons imaginer le client qui envoie des coordonnées et le serveur qui lui en redonne d'autres (échange fort probable pour un jeu de plateau par exemple) :

$server->on('binary-message', function (Hoa\Event\Bucket $bucket) {
    $data                          = $bucket->getData();
    $message                       = $data['message'];
    $point                         = [];
    list($point['x'], $point['y']) = array_values(unpack('nx/ny', $message));

    // compute a next point.

    $bucket->getSource()->send(
        pack('nn', $point['x'], $point['y']),
        null,
        Hoa\Websocket\Connection::OPCODE_BINARY_FRAME
    );

    return;
});

Les fonctions pack et unpack seront des alliés précieux dans ce cas.

Notons que les messages binaires peuvent également être fragmentés. Il faut utiliser l'opcode OPCODE_BINARY_FRAME à la place de OPCODE_TEXT_FRAME puis continuer avec OPCODE_CONTINUATION_FRAME comme nous l'avons appris.

Écrire un client

La classe Hoa\Websocket\Client permet d'écrire un client manipulant le protocole WebSocket. Cette classe hérite de Hoa\Websocket\Connection, tout comme Hoa\Websocket\Server. La communication s'effectue à travers un client de socket. Nous utiliserons la classe Hoa\Socket\Client (de la bibliothèque Hoa\Socket) pour remplir ce rôle.

Autant Hoa\Websocket\Server est capable de traiter avec des clients supportant plusieurs versions du protocole WebSocket, autant Hoa\Websocket\Client utilise uniquement le protocole de la RFC6455. C'est à dire que le client n'est capable de parler qu'avec un serveur supportant la RFC6455.

Démarrer une connexion avec le serveur

Comme pour le serveur, le client hérite de Hoa\Websocket\Connection. Nous retrouvons alors les mêmes méthodes send, close, run etc., ainsi que les mêmes écouteurs. Et comme pour le serveur, les écouteurs doivent être positionnés sur le client.

Ainsi, pour démarrer un client, nous écrirons :

$client = new Hoa\Websocket\Client(
    new Hoa\Socket\Client('ws://127.0.0.1:8889')
);
$client->on('message', function (Hoa\Event\Bucket $bucket) {
    $data = $bucket->getData();
    echo 'received message: ', $data['message'], "\n";

    return;
});

Le client peut fonctionner en mode loop, comme le serveur, avec la méthode run. Dans ce cas, nous devrons écrire :

$client->run();

Ou alors, pour un échange séquentiel, nous devons appeler manuellement la méthode Hoa\Websocket\Client::connect :

$client->connect();

Si le serveur ne supporte pas le bon protocole, une exception Hoa\Websocket\Exception\BadProtocol sera levée.

Échanges et diffusions de messages

Pour envoyer un message, nous utiliserons la méthode Hoa\Websocket\Connection::send. Son fonctionnement a été décrit précédemment pour le serveur. Il est identique.

A contrario, pour recevoir un message, nous utiliserons la méthode Hoa\Websocket\Client::receive. Les messages reçus du serveur vont déclencher les écouteurs. Ainsi, nous allons envoyer un message au serveur, puis nous allons attendre une réponse, ceci deux fois de suite :

$client->send('foobar');
$client->receive();

$client->send('bazqux');
$client->receive();

$client->close();

La méthode Hoa\Websocket\Client::receive n'a aucun argument.

Précision sur le handshake et l'hôte

Pour que le handshake soit complet, il est nécessaire d'envoyer l'en-tête HTTP Host, représentant le nom de l'hôte. Lorsque le client est exécuté à travers un serveur HTTP, l'hôte de ce serveur sera utilisé s'il est disponible. Sinon, s'il n'est pas disponible, ou si nous exécutons le client en ligne de commande par exemple, nous devons préciser un hôte avec la méthode Hoa\Websocket\Client::setHost, avant de connecter le client (avant l'appel de Hoa\Websocket\Connection::run ou Hoa\Websocket\Client::connect) ; ainsi :

$client->setHost('hoa-project.net');
$client->connect();

// …

Pour savoir si l'hôte est connu, nous pouvons utiliser la méthode Hoa\Websocket\Client::getHost. Elle retournera null si le nom de l'hôte est introuvable. Ou sinon, au moment du handshake, une exception Hoa\Websocket\Exception\Exception sera levée.

Nœud personnalisé

Les classes Hoa\Socket\Server et Hoa\Socket\Client travaillent avec des nœuds : des objets qui représentent une connexion ouverte. La classe de base pour représenter un nœud est Hoa\Socket\Node. La bibliothèque Hoa\Websocket propose son propre nœud : Hoa\Websocket\Node. Nous pouvons encore étendre cette classe pour ajouter et manipuler des informations sur une connexion.

Par exemple, dans le cas d'une messagerie, nous pourrions stocker le pseudo du client :

class ChatNode extends Hoa\Websocket\Node
{
    protected $_pseudo = null;

    public function setPseudo ($pseudo)
    {
        $old           = $this->_pseudo;
        $this->_pseudo = $pseudo;

        return $old;
    }

    public function getPseudo()
    {
        return $this->_pseudo;
    }
}

Pour préciser au serveur ou au client de socket d'utiliser notre classe de nœud, nous devons utiliser la méthode Hoa\Socket\Server::setNodeName ou Hoa\Socket\Client::setNodeName de cette manière :

$server = new Hoa\Websocket\Server(
    new Hoa\Socket\Server('ws://127.0.0.1:8889')
);
$server->getConnection()->setNodeName('ChatNode');

Et après, dans nos écouteurs, nous pourrons utiliser notre méthode getPseudo par exemple :

$server->on('message', function (Hoa\Event\Bucket $bucket) {
    $node = $bucket->getSource()->getConnection()->getCurrentNode();

    var_dump($node->getPseudo());

    // …
});

Si vous mettez en place un protocole en utilisant le canal des WebSockets entre vos clients et votre serveur, les nœuds personnalisés seront très utiles pour stocker quelques informations récurrentes.

Conclusion

La bibliothèque Hoa\Websocket permet de créer des serveurs et des clients WebSocket pour plus d'interactivité dans vos applications. Le serveur est facilement extensible avec la notion de nœud, qui facilite le stockage et la manipulation de données utiles pour créer son propre protocole.

Une erreur ou une suggestion sur la documentation ? Les contributions sont ouvertes !

Comments

menu