Skip to content

Websocket

Dans cette leçon nous allons mettre en place un système de chat relativement simple qui utilise les websockets.

websocket-01.png

Mise en place de la partie client

L'interface utilisateur peut être réalisée à l'aide des codes HTML, JavaScript et CSS suivants. Ici le code HTML utilise l'API Bootstrap.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<div id="chat-controls" class="container">
  <div class="offset-md-3 col-md-6">
    <div class="row mb-1">
      <div class="col">
        <div class="input-group">
          <input type="text" class="form-control" id="websocket-server"
                value="ws://localhost:8084/ewt/websocket/{/output/application/name/@dir}/chat/{/output/session/@id}/{/output/documents/document[1]/@context}"/>
          <button type="button" class="btn btn-outline-primary"
                  onclick="websocket.connect()">Connecter</button>
          <button type="button" class="btn btn-outline-primary"
                  onclick="websocket.disconnect()">Déconnecter</button>
        </div>
      </div>
    </div>
    <div class="row mb-1">
      <div class="col">
        <div id="messages-history"></div>
      </div>
    </div>
    <div class="row">
      <div class="col">
        <div class="input-group">
          <input type="text" class="form-control" id="message-input"
                 placeholder="Saisir un message"/>
          <button type="button" class="btn btn-outline-primary"
                  onclick="websocket.sendMessage()">Envoyer</button>
        </div>
      </div>
    </div>
  </div>
</div>
<script src="{$resources-url}/js/websocket.js"></script>
<link rel="stylesheet" href="{$resources-url}/css/websocket.css"></link>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var websocket = {
    session : undefined,

    connect: function() {
        this.session = new WebSocket(document.getElementById('websocket-server').value);

        this.session.onmessage = (event) => {
            const f = document.getElementById("messages-history");
            const msg = event.data;
            let text = document.createElement('p');
            text.appendChild(document.createTextNode(msg));

            f.appendChild(text);
        }
    },

    disconnect: function() {
        this.session.close();
    },

    sendMessage: function() {
        if (document.getElementById('message-input').value != "") {
            this.session.send(document.getElementById('message-input').value);
        }

        document.getElementById('message-input').select();
    }
}
1
2
3
4
5
6
7
8
9
#messages-history {
  height: 200px;
  border: solid 1px #cccccc;
  overflow-y: auto;
}

#messages-history p {
  margin: 0;
}

Mise en place de la partie serveur

Il reste à mettre en place la partie serveur. L'URL construite à la ligne 7 du code HTML ci-dessus référence un script "chat". Nous allons donc créer un fichier qui corresponde à ce nom. Notre script s'appellera donc chat.ewts. Il est également possible de l'appeler chat.script au besoin.

Le script de la partie serveur est en réalité très simple : il doit permettre de traiter les 5 types d'événements susceptibles d'arriver en websocket. Il s'agit des événements open, message, binary, close et error.

Le script est le suivant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@accept(servlet = "websocket")

switch ($$.event) {
    case "open":
        $websocket.send("🤖 hello " & $request.getPrincipal(), { dest: "current" });
        $websocket.send("🤖 " & $request.getPrincipal() & " has joined", { dest: "others" });
        break;
    case "message":
        $websocket.send("😀 " & $$.content, { dest: "current" });
        $websocket.send("🌍 " & $$.content, { dest: "others" });
        break;
    case "binary":
        // non implémenté
        break;
    case "close":
        $websocket.send("🤖 " & $request.getPrincipal() & " has leaved", { dest: "all" });
        break;
    case "error":
        $logger.error($$);
        break;
}

La première ligne indique que le script ne peut être appelé que pour des requêtes adressées au servlet "websocket".

Les lignes suivantes traitent les 4 types d'événements possibles.

En cas de open, on envoie un message de salutation à l'utilisateur qui se connecte, et on annonce à tous les autres utilisateurs qu'une nouvelle personne s'est connectée.

En cas de message, on envoie un message à tous les participants. Ici on utilise des emoji pour distinguer les interlocuteurs.

En cas de close, on annonce à tous les participants qu'une personne s'est déconnectée.

En cas d'error, on ne fait rien de particulier si ce n'est de reprendre l'erreur dans le log.

Données binaires

Jusqu'à présent, nous nous sommes limités à des données textuelles. Il est également possible de gérer des données binaires. Pour cela deux solutions s'offrent à nous:

  1. Transmettre/Recevoir des données binaires (de type ArrayBuffer ou Blob en javascript)
  2. Encoder les données binaires en base64 (ou autre codage utilisant un alphabet standard) et les transmettre sous forme de texte, comme cela a été fait plus haut.

Nous allons explorer ces deux solutions.

Transmission de données binaires

Partie client

L'upload de document binaire nécessite une adaptation de l'interface utilisateur. Au niveau de la partie HTML, nous ne faisons que d'ajouter un champ "file" qui permettra de sélectionner un fichier. Le reste du code HTML ne change pas. Pour cette raison, nous ne reprenons ici qu'une partie du code HTML. Les lignes ajoutées sont mises en évidence.

Le code javascript nécessite plus de modifications. Le code CSS quant à lui ne change pas.

12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
...
<div class="row mb-1">
  <div class="col">
    <div id="messages-history"></div>
  </div>
</div>
<div class="row">
  <div class="col">
    <div class="input-group">
      <input type="file" class="form-control" id="binary-input"/>
    </div>
  </div>
</div>
<div class="row">
  <div class="col">
    <div class="input-group">
      <input type="text" class="form-control" id="message-input" placeholder="Saisir un message"/>
      <button type="button" class="btn btn-outline-primary" onclick="websocket.sendMessage()">Envoyer</button>
    </div>
  </div>
</div>
...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
var websocket = {
    session : undefined,

    connect: function() {
        this.session = new WebSocket(document.getElementById('websocket-server').value);
        this.session.binaryType = "arraybuffer";

        this.session.onmessage = (event) => {
            const f = document.getElementById("messages-history");
            const msg = event.data;

            let text = document.createElement('p');
            var content = "";
            if (typeof msg == "string") {
                content = document.createTextNode(msg);
            }
            else {
                var binary = "";
                var bytes = new Uint8Array(msg);
                var len = bytes.byteLength;
                for (var i = 0; i < len; i++) {
                    binary += String.fromCharCode(bytes[i]);
                }
                content = document.createElement("img");
                content.setAttribute("src", "data:image/png;base64, " +
                                            window.btoa(binary));
            }

            text.appendChild(content);
            f.appendChild(text);
        }
    },

    disconnect: function() {
        this.session.close();
    },

    sendMessage: function() {
        var file = document.getElementById('binary-input').files[0];
        if (file) {
            this.session.send(file);
        }

        if (document.getElementById('message-input').value != "") {
            this.session.send(document.getElementById('message-input').value);
        }

        document.getElementById('message-input').select();
    }
}

JavaScript permet de gérer les données binaires de différentes façons. Ici, nous utilisons le type Blob pour l'envoi au serveur et le type ArrayBuffer pour traiter les données reçues.

Les modifications portent sur la réception de données et sur l'envoi de données.

Réception
La ligne 6 indique que nous souhaitons recevoir des données binaires de type ArrayBuffer. Le bloc de lignes 14 à 16 reprend le comportement qui a déjà été mis en place précédemment pour les données texte. Les lignes 18 à 25 quant à elles gère la réception de données binaires. Ici nous faisons le choix de traiter les données comme des images PNG. C'est évidemment un choix arbitraire qui ne peut fonctionner que si l'on sait explicitement que les données reçues sont de ce type. Nous revenons sur la problématique du type MIME plus bas.

Envoi
La méthode sendMessage est modifiée pour gérer l'envoi de données. Elle vérifie si l'input "file" contient un fichier et se charge de transmettre celui-ci en tant que Blob le cas échéant.

Partie serveur

De base, une application Ewt intègre un buffer capable de stocker jusqu'à 1MB de données binaires. Il est possible d'élargir ce buffer en ajoutant ou complétant le bloc suivant dans la configuration de l'application, en adaptant la taille du buffer. Ici la valeur est exprimée en bytes. Dans l'exemple ci-dessous, on étend donc la taille du buffer à 10MB.

<websocket>
  <maxBinaryMessageBufferSize>10485760</maxBinaryMessageBufferSize>
</websocket>

Le script chat.ewts nécessite quant à lui peut de modifications : on implémente le cas "binary" qui n'était pas géré dans la version précédente. Le reste du code ne change pas.

case "binary":
    $websocket.send($$.content, { dest: "all" });
    break;

Bilan

L'envoi et la réception de données binaires sont fonctionnels, mais ils sont limités par le fait que seule les données sont transmises, sans les métadonnées qui gravitent autour. On perd ainsi des informations utiles comme le nom de fichier et le type MIME. Pour pouvoir intégrer ces éléments dans les requêtes et réponses WebSocket, nous allons devoir construire un objet JSON dans lequel les données binaires sont encodées.

Encodage des données binaires

L'idée de cette variante est d'encoder les données binaires en base64 et de les inclure dans un objet JSON. Nous en profiterons pour y ajouter les métadonnées relatives au fichier (son nom et son type MIME) bien que dans le cadre de cette leçon nous ne nous en servirons pas.

L'objet JSON sera alors converti en String et transmise sous forme de texte et non plus sous forme de données binaires.

Le volume des données transmissibles sous forme de String est également limité. Par défaut, il est de 8192 bytes par message. Si on souhaite intégrer une représentation base64 des données, il convient d'augmenter la limite du buffer. Pour ce faire, il faut d'ajouter ou compléter le groupe websocket du fichier de configuration de l'application avec la propriété suivante (adapter la valeur selon vos besoins):

<websocket>
  <maxTextMessageBufferSize>10485760</maxTextMessageBufferSize>
</websocket>

Ici nous avons augmenté la limite à 10MB.

Partie client

Le code HTML est inchangé par rapport à la version précédente. Par contre le code javascript change sensiblement. En réalité nous profitons d'améliorer le design de la zone de messages, même si cela reste largement perfectible. Le rendu final ressemble à ceci:

websocket-02.png

Le nouveau code JavaScript est repris ci-après en intégralité.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
var websocket = {
    session : undefined,

    connect: function() {
        this.session = new WebSocket(document.getElementById('websocket-server').value);
        this.session.binaryType = "arraybuffer";

        this.session.onmessage = (event) => {
            const container = document.getElementById("messages-history");                

            // Le message en entrée est un map qui a la forme suivante:
            //     { from: "x",
            //       isOwnMessage: false,
            //       timestamp: "2024-03-20 09:36:12.123",
            //       text: "bla bla",
            //       file: { data: "data:image/png;base64, iVBORRw0...",
            //               mimetype: "image/png", filename: "somefile.png" } }
            // Les éléments text et file sont optionnels

            let div = document.createElement("div");

            try {
                let json = JSON.parse(event.data);

                if (json.isOwnMessage) {
                    div.setAttribute("class", "border m-2 p-2 float-end");
                }
                else {
                    div.setAttribute("class", "border m-2 p-2 float-start");
                }

                let head = document.createElement("div");
                head.setAttribute("class", "d-flex");

                let author = document.createElement("span");
                author.setAttribute("class", "me-auto")
                author.appendChild(document.createTextNode(json.from));
                head.appendChild(author);

                let timestamp = document.createElement("small");
                timestamp.appendChild(document.createTextNode(json.timestamp));
                head.appendChild(timestamp);

                div.appendChild(head);

                let body = document.createElement("div");
                if (json.file) {
                    let img = document.createElement("img");
                    img.setAttribute("src", json.file.data);
                    body.appendChild(img);
                    body.appendChild(document.createElement("br"));
                }

                if (json.text) {
                    body.appendChild(document.createTextNode(json.text));
                }

                div.appendChild(body);
            }
            catch (e) {
                console.log(e);
                let body = document.createTextNode(event.data);
                div.appendChild(body);
            }

            container.appendChild(div);

            // clearfix (pour forcer le message suivant à s'afficher en-dessous
            // et non pas à côté du message actuel)
            div = document.createElement("div");
            div.setAttribute("class", "clearfix");
            container.appendChild(div);
        }
    },

    disconnect: function() {
        this.session.close();
    },

    sendMessage: function() {
        var msg = {};

        if (document.getElementById('message-input').value != "") {
            msg.text = document.getElementById('message-input').value;
        }

        var file = document.getElementById('binary-input').files[0];
        if (file) {
            var self = this;
            var reader = new FileReader();                
            reader.onloadend = function() {
                msg.file = {
                    data: reader.result,
                    filename: file.name,
                    mimetype: file.type
                }

                // on envoie la requête
                self.session.send(JSON.stringify(msg));
            }
            reader.readAsDataURL(file);
        }
        else if (msg.text) {
            // on envoie la requête
            this.session.send(JSON.stringify(msg));
        }

        document.getElementById('message-input').select();
    }
}

Partie serveur

La partie serveur est également passablement remaniée pour que les requêtes et réponses soient traitées comme des objets json.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@accept(servlet = "websocket")

switch ($$.event) {
    case "open":
        var response = {
            from: "🤖",
            timestamp: $cal.timestamp(),
            isOwnMessage: false
        };

        response.text = "hello " & $request.getPrincipal();
        $websocket.send($json.toJson(response), { dest: "current" });

        response.text = $request.getPrincipal() & " has joined";
        $websocket.send($json.toJson(response), { dest: "others" });
        break;
    case "message":
        var request = $json.parse($$.content);

        if (request != null) {
            var response = request;
            response.timestamp = $cal.timestamp();

            response.from = "😀";
            response.isOwnMessage = true;
            $websocket.send($json.toJson(response), { dest: "current" });

            response.from = "🌍";
            response.isOwnMessage = false;
            $websocket.send($json.toJson(response), { dest: "others" });
        }
        break;
    case "binary":
        $websocket.send($$.content, { dest: "all" });
        break;
    case "close":
        $logger.debug("closing websocket session");

        var response = {
            from: "🤖",
            timestamp: $cal.timestamp(),
            isOwnMessage: false,
            text: $request.getPrincipal() & " has leaved"
        };
        $websocket.send($json.toJson(response), { dest: "all" });
        break;
    case "error":
        $logger.error($$);
        break;
}

Conclusion

Nous avons vu comment mettre en place un système de chat simple. En réalité la majeure partie du travail consiste à construire l'interface utilisateur en HTML, JavaScript et CSS. La partie serveur quant à elle est relativement simple à construire.