Socket.io

De EduTech Wiki
Aller à la navigation Aller à la recherche
Initiation à la pensée computationnelle avec JavaScript
Module: JavaScript sur le serveur ◀▬ ▬▶
◀▬▬▶
à finaliser avancé
2018/06/16
Prérequis
Catégorie: JavaScript

Introduction

Socket.io est un module de Node.js qui permet de créer des Web Sockets, c'est-à-dire des connections bi-directionnelles entre clients et serveur qui permettent une communication en temps réel sur un autre protocole que le protocole http normalement utilisé dans les pages web. Ce type de technique est utilisée pour créer des applications telles que des systèmes de communication en temps réel (i.e. des Chats), des jeux multi-utilisateur, des applications de collaboration, etc.

Bien que Socket.io soit un module qui peut être utilisé individuellement, la plupart des exemples contenus dans la page utilise également le module Express.js.

Objectifs de cette page

Cette page fait principalement référence au cours STIC I du Master MALTT et s'inscrit dans la perspective d'aborder les rudiments de la programmation à travers JavaScript. Plus en détail, Socket.io permet de montrer le fonctionnement d'un protocole plus complexe par rapport au protocole http. Grâce à ce type de communication, il est possibile de créer des applications pédagogiques, notamment en relation avec l'Apprentissage collaboratif.

De plus, la communication WebSocket renforce ultérieurement les concepts de programmation événementielle et d'interactivité, car pour mettre en place des applications qui se mettent à jour en temps réel il faut entremêler des événements côté-client et côté-serveur.

Pour comprendre le contenu de cette page la lecture des pages suivantes est conseillée :

Avertissement de sécurité

Warning.png

Le même avertissement de sécurité de Node.js s'applique dans le cas de Socket.io, car les technologies utilisées sont très similaires.

Veuillez également être attentif au fait que les exemples illustrés dans cette page ont exclusivement une finalité propédeutique à l’apprentissage des concepts fondamentaux du développement web et ne sont pas conçus pour une utilisation en dehors d’un environnement contrôlé par le développeur (e.g. localhost).

Exemple conceptuel: messagerie instantanée

Un exemple conceptuel peut contribuer à mettre les informations théoriques et pratiques de cette page dans une perspective plus concrète: un application de messagerie instantanée. Le principe est assez simple : un client envoie un message au serveur, le serveur reçoit ce message et le transmets aux autres clients connectés. Voici un enregistrement vidéo d'une simple application dans deux fenêtres du navigateur (représentant deux utilisateurs différents) :

Enregistrement vidéo d'une simple application de messagerie instantanée avec WebSockets.

Dans ce simple mécanisme il y a tout de même plusieurs événements qui se passent :

  1. Côté client : l’utilisateur écrit du texte et clique sur un bouton pour envoyer le message. On a donc un événement qu’on peut identifier avec le nom « messageFromClient » associé à des données de type textuel ;
  2. Côté serveur : le serveur doit « écouter » pour des événements de type « messageFromClient » pour être prêt à gérer l’information envoyé par le client ;
  3. Côté serveur : lorsque le serveur reçoit un événement « messageFromClient », il émet à son tour un événement qui transmet le message aux clients connectés. On peut identifier cet événement avec le nom « messageFromServer », associé encore une fois à des données de type textuel ;
  4. Côté client : le client doit « écouter » pour des événements de type « messageFromServer » pour être prêt à gérer l’information retransmise par le serveur ;
  5. Côté client : lorsque le client reçoit un événement « messageFromServer », il utilise les données textuelles reçues pour mettre à jour le DOM de la page HTML et afficher le nouveau message.

Comprendre les WebSockets

Figure 1. Avant les WebSockets, le "temps réel" était en réalité un décalage régulier. Entre 1. et 3. ou 1. et 5. le temps pouvait varier entre quelques seconds et quelques minutes.
Figure 2. Avec les WebSockets la connectivité est simultanée. Le délai entre 1. et 2. est de 50-100 ms!

Différence par rapport au protocole http

Le protocole http est basé sur l’architecture requête/réponse qui peut être considérée comme la somme de deux mouvements unidirectionnels : la requête du client dans un sens et la réponse du serveur dans l’autre sens. Une fois que cet échange a eu lieu, il n’y a plus de contact entre le client et le serveur, si ce n’est à travers l’établissement d’un nouvel échange requête/réponse (Figure 1). Le protocole WebSocket, au contraire, crée une véritable connexion entre le client et le serveur, ce qui permet une communication bidirectionnelle en temps réel : le client peut envoyer un message au serveur, et le serveur peut envoyer un message à un client, sans la nécessité d’une requête/réponse http. À travers ce nouveau type de connexion, il est également possible de créer un « groupe » de communication entre tous les clients qui ont établi une connexion WebSocket avec le même serveur (Figure 2). Le principe est très simple :

  1. Un client envoie un message au serveur
  2. Le serveur reçoit le message
  3. Le serveur envoie ce même message (ou un message modifié) à tous les clients connectés

Un upgrade du protocole http

Il faut cependant considérer que le protocole des WebSockets nécessite quand même du protocole http pour établir la première connexion. En effet, le protocole des WebSockets est une sorte de « upgrade » du protocole http. Pour que cet upgrade ait lieu, il y a deux prérequis nécessaires :

  1. Le serveur est capable de gérer le protocole WebSockets : cette capacité est disponible justement grâce à la combinaison Express.js/Socket.io
  2. Le client est capable de gérer le protocole WebSockets : cette capacité est disponible à travers l’API HTML5 des WebSockets qui est compatible avec pratiquement tous les browsers récents (en 2016)

Le « handshake »

Lorsque ces prérequis sont atteints, une connexion WebSockets s’établit de la manière suivante :

  1. Un client établit une simple connexion http avec un serveur, ce qui se fait par exemple à travers la requête d’une simple page HTML avec un URL
  2. Sur cette page HTML est présent un script JavaScript qui s’occupe de démarrer une connexion WebSocket. Cette connexion s’établit seulement si le navigateur supporte l’API HTML5 des WebSockets
  3. La demande de connexion WebSockets est donc envoyée au serveur. Si le serveur supporte ce type de connexion, alors la connexion bidirectionnelle est formée : le client et le serveur seront en contact tant que le client garde la page avec le script JavaScript ouverte dans son navigateur

Ce type de mécanisme s’appelle « handshake », car c’est comme si les deux entités (le client et le serveur) s’accordent sur la possibilité d’établir un nouveau « pacte » (la connexion WebSockets) sur la base d’une prémisse existante (une connexion http établie et la capacité des deux entités de gérer le nouveau protocole).

L’échange de messages

Une fois le « handshake » achevé, l’échange de messages entre client et serveur peut commencer. L’échange se fait à partir du moment où un événement côté client ou côté serveur déclenche l’envoi d’un message. L’échange entre les deux entités se compose de deux éléments :

  1. Le type d’événement (obligatoire)
  2. Des données associées à cet événement : les données peuvent être de différents types, du simple texte dans le cas d’un système de messagerie instantanée à des streams audio/vidéo pour des applications plus complexes

Pour que l’échange se passe correctement il faut donc que le client et le serveur, dans la logique de leurs applications, disposent d’un gestionnaire d’événements qui :

  • Sache quoi faire pour émettre un événement (avec ou sans données)
  • Sache quoi faire pour recevoir (et éventuellement réagir) à un événement

Avantages des WebSockets

Le protocole WebSockets présente plusieurs avantages par rapport au protocole http :

  • Le temps de latence pour l’envoi et la réception d’un message est d’environ 50-100 ms. En fonction du temps de réaction des êtres humains (perception, traitement, etc.), ce délai peut être considéré de facto du « real time ».
  • L’échange entre client et serveur (ou entre serveur et client) se fait seulement s’il y a un événement qui propose un changement. Ceci réduit le trafic des requêtes/réponses à l’essentiel, tandis que dans le cas des applications qui utilisent le protocole http, souvent la plupart des requêtes asynchrones est faite juste pour contrôler s’il y a quelque chose de nouveau. Toutes ces requêtes ne sont pas nécessaires avec les WebSockets.
  • La communication en temps réel permet une vraie synchronisation entre les clients, ce qui permet de créer des applications collaboratives où les apprenants partagent à tout moment le même écran et peuvent donc discuter/contribuer/etc. plus facilement.
  • L’échange de messages au niveau client se fait à travers JavaScript, ceci signifie que les informations obtenues dans les échanges peuvent être utilisées par la logique de l’application front-end.

Utilisation de Socket.io

Installation des modules

Comme tout module Node.js, Socket.io s'installe avec npm :

npm install socket.io

Bien que Socket.io puisse être utilisé individuellement, nous allons le combiner avec Express.js pour fournir également le protocole http nécessaire pour le "handshake" (voir plus haut dans la section théorique).

npm install express

Mettre en place un serveur HTTP+WebSocket

Le fait qu'on nécessite deux protocoles différents implique, conceptuellement, deux serveurs différents: un serveur qui gère le protocole http et un serveur qui gère le protocole WebSocket. En effet, il est tout à fait possible que la connexion WebSocket se fasse sur un autre serveur par rapport au serveur qui met à disposition la page HTML dans laquelle se trouve le script qui crée la connexion WebSocket. En utilisant l'environnement de Node.js, il est possibile de combiner les deux serveurs dans une seule application:

  • Express.js "écoute" la requête http et se charge d'envoyer en réponse une page qui contient le JavaScript pour établir une connexion WebSocket et gérer par la suite l'échange des messages ;
  • Socket.io "écoute" et retransmet les messages entre les clients connectés et le serveur

La possibilité de combiner les deux serveurs dans la même application permet notamment de partager des informations (e.g. bases des données, etc.).

Côté serveur

D'abord, il faut mettre en place un fichier JavaScript qui contient l'application pour créer le serveur. Pour comprendre le code suivant il faut se rappeler que Express.js n'est qu'une extension du module natif http de Node.js. Le code sera ensuite expliqué en détail :

//Code du script Node app.js
//1. Http
var http = require('http');

//2. Express.js
var express = require('express');
var app = express();

//Create a route for the first http connection
app.get('/', function (request, response) {
    //The file will include the socket.io.js file to establish the socket connection
    response.sendFile(__dirname + '/index.html');
});

//3. Create a server that will serve both http and socket connection using the app function of Express.js
var server = http.createServer(app);

//4. Socket.io
//Pass the server to the socket.io to handle socket connection
var io = require('socket.io')(server);

//5. This function will be executed every time a user connect to the socket through the "/" express route
io.on('connection', function (socket) {
    console.log("A new client connected!");
});

//6. Listen to the "shared" server (not the Express.js app)
server.listen(3000, console.log("Listening to port 3000"));

Voici les passages les plus importants de ce code :

  1. D'abord il faut inclure le module natif http qui nous permet par la suite de "relier" les serveurs Express et Socket.io dans une seule application
  2. Ensuite nous créons une "simple" application Express avec une seule route principale. Cette route envoie simplement en réponse le contenu d'un fichier index.html dont le code est illustré dans la partie côté-client.
  3. Nous utilisons la méthode createServer() du module natif http pour créer un serveur http et nous utilisons l'application Express.js pour gérer le mécanisme requête/réponse
  4. Par la suite, nous passons au serveur Socket.io qui est associé au même serveur http à travers la variable (server)
  5. Le socket "écoute" les connexions qui seront établies à travers le fichier index.html servi par Express
  6. On lance les "deux serveurs" sur la porte 3000 pour que notre application combinée HTTP + WebSocket soit disponible à l'adresse http://localhost:3000

Côté client

Il nous faut à ce moment-là le fichier HTML qui contient le script JavaScript qui démarre la connexion WebSocket. Il faut savoir à ce propos que le module Socket.io met à disposition un fichier JavaScript avec tout le code nécessaire pour gérer la connexion et l'échange des messages côté-client. Ce fichier est mis à disposition à travers une route /socket.io/socket.io.js. Cela signifie que ce fichier n'existe pas physiquement dans l'arborescence de votre application qui se compose (dans ce cas) de la manière suivante :

|- myapp
  |- node_modules --> contient express et socket.io (qui nécessite de beaucoup d'autre modules)
    |- ...
  - app.js --> votre script côté-serveur
  - index.html --> votre page

Voici le code du fichier HTML :

<!doctype html>
<html>

<head>
    <meta charset="UTF-8">
    <title>Socket.io basic example</title>
</head>

<body>
    <h1>This is a page connected with a web socket</h1>
    <p><a href="/" target="_blank">Open the page in a new tab</a> to add a new connection.</p>
    <p>Look at the console log of your command line to see the messages.</p>
    <!-- include the socket.io.js file, which does not "physically" exists, but is provided directly by the server -->
    <script src="/socket.io/socket.io.js"></script>
    <script>
        //Start the socket connection
        var socket = io();
    </script>
</body>

</html>

Le script côté-client est également disponible en forme de CDN, voir le site officiel pour obtenir l'URL absolu.

Résultat attendu

La vidéo montre le résultat attendu de cette mise en place: chaque fois qu'une nouvelle fenêtre du navigateur (représentant un utilisateur différent) accède à la page http://localhost:3000, la console du script app.js affiche un message qui confirme la connexion.

Chaque fois qu'un nouveau utilisateur accède à la page, une nouvelle connexion socket est établie

L'échange de messages: .on() et .emit()

La communication entre client et serveur, et entre serveur et client se fait à travers deux méthodes :

  1. .emit('événement', [données]) pour l'émission
  2. .on('événement', callback([données])) pour la réception

.emit()

La méthode .emit() détermine quel type d'événement produire. Le nom de l'événement peut être arbitraire, il suffit de choisir un nom qui peut bien décrire ce qui se passe, par exemple :

//Un utilisateur se connecte
.emit('user added', 'User145')
//Un message de bienvenue est envoyé
.emit('greeting', {
  from: 'User145',
  message: 'Hello!' 
})

.on()

La méthode .on() est un type d'eventListener qui déclenche une fonction de callback lorsqu'un événement est émis. En relation avec les exemples de .emit() :

//Réagir à la connexion d'un utilisateur
.on('user added', function(user) {
  console.log(user + " just entered the room!"); //--> User145 just entered the room
});
//Réagir à l'envoi d'un message de bienvenue
.on('greeting', function (data) {
  console.log(data.from + " says: + data.message); //--> User145 says: Hello!  
});

Les deux méthodes sont appliquées de manière spéculaire selon la direction du message. Par convention, nous allons garder les noms de variables de la mise en place (voir plus haut) :

  • io pour le serveur
  • socket pour le client

Message du serveur au client

Dans le cas d'un message du serveur au client, nous avons la combinaison suivante,

au niveau serveur :

//In the app.js file
//... Same code as above
//5. This function will be executed every time a user connect to the socket through the "/" express route
io.on('connection', function (socket) {

    //1. This function will emit a message from the server to all clients connected, saying that a new user has connected
    io.emit('for everyone', 'A new client has joined us!');

    //2. This function will emit a message just to the client itself
    socket.emit('just for you my socket', 'Welcome to this socket, my Client!');

    //3. This function will emit a message to every client connected EXCEPT the client itself
    socket.broadcast.emit('for everyone else', 'Psst, the new client cannot read this...');
});

Le serveur peut envoyer trois types de messages:

  1. Un message destiné à tous les clients connectés (inclut le client lui-même)
  2. Un message destiné seulement au client lui-même, identifié grâce à l'argument socket dans la fonction de callback io.on('connection', function (socket) {});
  3. Un message destiné à tous les clients connectés, sauf le client lui-même

au niveau client :

dans le fichier index.html à l'intérieur de la balise script:

//Inside the script in index.html        
//Start the socket connection
var socket = io();
//Listen for the "for everyone" event from the server
socket.on('for everyone', function (msg) {
    console.log(msg)
});

//Listen for the "just for you my socket" event from the server
socket.on('just for you my socket', function (msg) {
    console.log(msg)
});

//Listen for the "for everyone else" event from the server
socket.on('for everyone else', function (msg) {
    console.log(msg);
});
</script>
Le résultat de trois clients qui se connectent l'un après l'autre.

Message du client au serveur

Dans le cas d'un message du client au serveur, par contre, la combinaison serait la suivante,

Au niveau client, dans le fichier index.html à l'intérieur du tag script  :

  //In the script tag of index.html
  //Start the socket connection
  var socket = io();
  //Send a message to the server
  socket.emit('greeting', 'Hello!');

Au niveau serveur:

//In the app.js file
//... Same code as above
//5. This function will be executed every time a user connect to the socket through the "/" express route
io.on('connection', function (socket) {
    console.log("A new client connected!");
    //Receives greetings from the socket using the socket reference in callback function of io.on('connection', function (socket) ...
    socket.on('greeting', function (msg) {
      console.log("The client " + socket.id + " says " + msg + " to the server");
    });
});

Veuillez noter que l'événement .on('greeting') au niveau serveur est associé par référence à l'argument (socket) dans la fonction de callback qui démarre la connexion et non pas au serveur lui-même. En effet, lorsqu'on déclare le listener

io.on('connection', function (socket) { 
  //Tous les échanges vont ici 
}); 

nous établissons une connexion bi-directionnelle avec un client spécifique (le socket de l'argument). Donc il faut qu'on puisse déterminer quel client particulier a envoyé le 'greeting'. On peut s'apercevoir qu'il s'agit d'un client particulier grâce à la propriété socket.id qui génère automatiquement une suite de caractères pour créer un identifiant unique pour chaque client connecté au serveur.

L'événement io.on() est donc pratiquement limité à l'établissement d'une nouvelle connexion entre chaque client et le serveur, mais à l'intérieur de cette connexion, les événements en réception concerne le socket particulier auquel la connexion se réfère.

Code de l'exemple conceptuel

Enfin, nous pouvons combiner les messages du client au serveur et du serveur au client pour créer une application qui partage les informations selon le mécanisme suivant :

  1. Le client envoie un message au serveur
  2. Le serveur écoute ce message et retransmet le message aux clients connectés
  3. Les clients connectés reçoivent le message et mettent à jour leur interface avec la nouvelle information

C'est le principe à la base de l'exemple conceptuel en introduction de cette page, dont le code complet est le suivant.

Côté serveur

Le code du fichier app.js est le suivant:

//Http
var http = require('http');

//Express.js
var express = require('express');
var app = express();

//Create a route for the first http connection
app.get('/', function (request, response) {
    //The file will include the socket.io.js file to establish the socket connection
    response.sendFile(__dirname + '/index.html');
});

//Create a server that will serve both http and socket connection using the app function of Express.js
var server = http.createServer(app);

//Socket.io
//Pass the server to the socket.io to handle socket connection
var io = require('socket.io')(server);

//This function will be executed every time a user connect to the socket through the "/" express route
io.on('connection', function (socket) {
    console.log("A new client connected!");
    //Give the client a random username
    socket.username = "User" + Math.ceil(Math.random() * 100);
    //This function will be executed when the user disconnect (i.e. leaves the "/" express route)
    socket.on('disconnect', function (socket) {
        console.log("A client has disconnected!");
    });
    //This function will emit a message from the server to all clients connected, saying that a new user has connected
    //See the index.html to see how this communication is handled on the client
    io.emit('user connected');

    //This function will be executed when the client send a 'message' event
    socket.on('messageFromClient', function (data) {
        //Add the socket.username to identify the client (should be replaced by a chosen username in a real application)
        var text = socket.username + " says: " + data;
        //Emit the message to all clients, included the client itself that has sent the message
        io.emit('messageFromServer', text);
    });
});

//Listen to the "shared" server (not the Express.js app)
server.listen(3000, console.log("Listening to port 3000"));

Côté-client

Le code du fichier index.html est le suivant:

<!doctype html>
<html>

<head>
    <meta charset="UTF-8">
    <title>Socket.io simple chat example</title>
</head>

<body>

    <h1>Socket.io simple chat example</h1>
    <p>Use the text input to send a message to the server:</p>
    <input id="messageTxt" type="text">
    <button id="sendBtn">Send</button>
    <ul id="messagesList">
        <!-- Dynamically generated list of messages sent from the different clients connected to the server -->
    </ul>
    <script src="/socket.io/socket.io.js"></script>
    <script>
        //Start the socket connection
        var socket = io();
        //Listen for the "user connected" event
        socket.on('user connected', function () {
            console.log("A new user has connected!")
        });
        //Identify the interactive elements in the DOM
        var msg = document.getElementById("messageTxt");
        var btn = document.getElementById("sendBtn");
        var list = document.getElementById("messagesList");
        //Send a message when the user clicks on the button
        btn.addEventListener('click', function () {
            //Emit a message event with the content of the text input as value
            socket.emit('messageFromClient', msg.value);
            msg.value = '';
        });
        //Add the content of each message that the clients receive from the server
        socket.on('messageFromServer', function (data) {
            var item = document.createElement('li');
            item.innerHTML = data;
            list.appendChild(item);
        });
    </script>
</body>

</html>

Techniques avancées

Socket.io propose des techniques plus complexes par rapport à l'exemple présenté dans cette page, mais le principe de base reste le même : l'échange .emit() vs. .on() entre client et serveur. Ces techniques peuvent ensuite être combinées avec les API de JavaScript pour créer des applications complexes. Voici un aperçu de quelques possibilités.

"Rooms" et "namespaces"

Une technique qui pourrait être utile dans le développement d'application multi-utilisateur concerne la possibilité d'utiliser des "rooms" et des "namespaces" pour créer des canaux de communication restreints à certains utilisateurs ou contextes.

La technique des rooms, par exemple, permet de créer des "groupes" d'utilisateurs. Pour créer un "groupe" on utilise la méthode .join(groupe);

io.on('connection', function(socket){
  socket.join('stic-1');
});

À ce moment, l'émission des messages peut être limitée aux sockets qui font partie du "groupe/room" stic-1 avec la notation suivante :

io.to('stic-1').emit('welcome', 'Welcome to the STIC I room!'):

Échange non limité à du texte

Les échanges à travers un WebSocket ne se limitent pas à du contenu textuel comme dans l'exemple de la messagerie instantanée. On peut en effet envoyer tout type de données, et par conséquent développer des applications qui favorisent la collaboration à distance avec différents types de médias. La vidéo suivante, par exemple, montre une simple application qui utilise l'API HTML5 audio et video pour synchroniser la lecture d'une vidéo, qui pourrait être accompagnée d'autres systèmes de communication pour commenter, critiquer, etc. le contenu de la vidéo:

Simple application qui utilise l'API HTML5 audio et video pour synchroniser la lecture d'une vidéo.

Le même principe peut s'appliquer notamment à l'élément Canvas pour créer des dessins, ou encore à la technique Drag&Drop disponible grâce à jQuery UI.

Master/Client

Le fait de disposer de "deux serveurs" (http + socket) permet d'avoir des clients connectés au même socket à travers deux pages http différentes (i.e. avec URL différents). Cette option peut par exemple être exploitée pour créer des associations Master/Client, dans lesquelles le "Master" peut faire des opérations que le "Client" ne peut pas faire, mais peut quand même bénéficier de la communication en temps réel. Voici l'enregistrement d'une application qui prévoit:

  • une page "master" (avec route "/master") dans laquelle l'utilisateur peut écrire du code HTML
  • une page "client" (avec route "/") dans laquelle les utilisateurs peuvent voir le code et le résultat que ça donne sur une page
Exemple d'application Master/Client avec deux pages http différentes connectées au même socket.

Sécurité

Lorsque vous faites un chat tel que présenté sur cette page au point 4.3, les utilisateurs peuvent utiliser les balises html pour mettre en forme leur texte. Ils peuvent donc aussi utiliser la balise <script> et insérer du code javascript dans la fenêtre des autres utilisateurs, ce qui est potentiellement dangereux. Pour éviter cela, le module "ent" peut être utilisé. Pour l'installer, ouvrez l'invite de commandes dans le répertoire de votre application et entrez:

npm install ent

Une fois le module installé, vous pouvez l'inclure dans le fichier javascript côté serveur comme les autres modules :

//In the app.js file
var ent = require("ent");

Ensuite, vous pourrez l'utiliser pour encoder le texte reçu via l'événement "messageFromClient" et le renvoyer via l'événement "messageFromServer". Le texte est encodé de manière à être strictement considéré comme des chaines de caractères par le navigateur. Ainsi, les balises utilisées dans les messages seront affichées en clair et pas interprétée. Il en va de même pour le code javascript, qui sera affiché en clair, tout simplement.

//listen to the 'messageFromClient' event 
socket.on('messageFromClient', function (data) {
  //encode the message
  data = ent.encode(data);
  //emit encoded message
  io.emit('messageFromServer', data);
});

Liens

Ressources
Tutoriels