Développer un diffuseur de messages dans une application Grails 3 avec WebSocket et STOMP

Imaginez que vous vouliez développer une application web capable de diffuser des messages (comme des nouvelles par exemple) à des utilisateurs qui y souscriraient, et que ces messages apparaissent pratiquement instantanément dans leurs navigateurs, par exemple dans une chronologie graphique.

C'est tout le propos de l'application Grails 3 odelia-grails-broadcaster dont je vous partage le code source, et qui utilise le plugin Spring Websocket Grails Plugin.

Grâce à ce plugin qui facilite l'utilisation du support de STOMP (Simple Text Oriented Messaging Protocol) sur WebSocket du framework Spring 4 dans une application Grails, on peut échanger facilement des messages entre un serveur et un navigateur web.
De plus, Spring Websocket Grails Plugin vient avec une configuration par défaut (utilisant un simple broker de messages en mémoire) qui permet d'être très vite opérationnel.

odelia-grails-broadcaster comprend un contrôleur, TimelineController, et deux vues :

  • la vue par défaut index.gsp, associée à l'action de contrôleur index, destinée à présenter les messages émis dans une chronologie visuelle élégante et responsive (grâce à Vertical Timeline) ;
  • la vue broadcast.gsp, qui permet d'afficher un simple formulaire pour saisir les éléments du message à diffuser auprès des abonnés ; lorsque le formulaire est validé, les données sont transmises à l'action broadcastMessage.

Diffuser un message (Post)

L'action broadcastMessage du contrôleur TimelineController est chargée de créer une nouvelle instance de la classe métier odelia.grails.broadcaster.Post avec les données soumises par le formulaire, de la sauvegarder en base de données, puis de la diffuser vers la destination /topic/channel, au moyen du bean Spring de type SimpMessagingTemplate, avec l'appel de méthode convertAndSend :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def broadcastMessage() {
    def post = new Post(params)

    if (post.save(flush: true)) {
        brokerMessagingTemplate.convertAndSend "/topic/channel", (post as JSON).toString()

        flash.message = "Post broadcasted!"
    }
    else {
        flash.error = "Oops, something goes wrong!"
    }

    redirect(action: "broadcast")
}

Le second argument passé pour l'appel convertAndSend est de type String et correspond à la représentation chaîne de caractères d'un objet Post sérialisé au format JSON ; les clients qui auront souscrit au topic /topic/channel recevront une frame STOMP contenant la représentation JSON de l'objet diffusé (payload).
Notez qu'il est aussi possible de transmettre un POJO/POGO qui serait converti automatique en JSON.

Dans l'application, j'ai choisi de personnaliser la conversion au format JSON au moyen du convertisseur grails.converters.JSON. La manière de convertir un objet de type Post en JSON est spécifié dans la classe BootStrap.groovy ainsi :

1
2
3
4
5
6
7
8
9
10
JSON.registerObjectMarshaller(Post) { Post post ->
    return [
        title : post.title,
        content : post.content,
        datetime: post.date.format('yyyy-MM-dd HH:mm'),
        date: post.date.format('MM/dd/yy'),
        time: post.date.format('H:mm'),
        category: post.category
    ]
}

La classe métier Post, qui est déclarée dans le fichier source grails-app\domain\odelia\grails\broadcater\Post.groovy se présente comme ceci :

1
2
3
4
5
6
7
8
9
10
11
12
class Post {
    String title
    Date date = new Date()
    String content
    String category

    static constraints = {
        title(blank: false)
        content(maxSize: 1000, blank: false)
        category(blank: false)
    }
}

Connexion et souscription client

Tout utilisateur qui ouvrira la page web d'URL localhost:8080/timeline (en supposant une exécution locale du conteneur web, et l'utilisation du port par défaut) avec son navigateur Internet, verra les Posts déjà diffusés, et cette page sera mise à jour automatiquement lors de l'arrivée d'un nouveau message.

C'est le code JavaScript du fichier timeline.js référencé dans la page qui le permet : en effet, cette page est générée dynamiquement, au moyen de la vue grails-app\view\timeline\index.gsp qui inclut notamment le tag <asset:javascript src="timeline.js"/>.

Voici un extrait du fichier grails-app\assets\javascripts\timeline.js :

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
//= require jquery-2.2.0.min
//= require modernizr.custom
//= require spring-websocket
//= require_self

$(function() { 
    var socket = new SockJS("/stomp");
    var client = Stomp.over(socket);
    
    var template = $('#liTemplate').html();
    function addPost(post, animate) {
        // See https://www.codementor.io/tips/7463752841/simple-javascript-jquery-templating
        // ...
    }

    client.connect({}, function() {
        client.subscribe("/topic/channel", function(message) {            
            var post = $.parseJSON(message.body);
            addPost(post, true);
        });

        client.subscribe("/app/channel.messages", function(message) {
            var posts = $.parseJSON(message.body);
            $.each(posts, function(index, value) {
                addPost(value, false);
            });
        });        
    });
});

Notez tout d'abord que la dépendance à librairie spring-websocket autorise l'utilisation de STOMP sur WebSocket, au moyen de la librairie SockJS pour la partie WebSocket. Cette dernière va permettre de pallier, entre autres, au manque éventuel du support de WebSocket dans un navigateur donné.

Lors du chargement de la page, le code ci-dessus établit la connexion avec la partie serveur, puis souscrit au topic /topic/channel, de manière à réagir à la diffusion d'un nouveau Post ; lorsqu'un message parvient, celui-ci est converti au format JSON et ensuite ajouté à notre timeline qui se présente sous la forme d'une liste <ul>.
La fonction JavaScript addPost (voir le code source du projet pour avoir le code complet) est chargée de l'ajout du nouvel élément, en utilisant un mécanisme simple de modèle ; il serait bien sûr plus judicieux de mettre en application une librairie plus adaptée à ce type de traitement.

Obtenir la liste initiale

Une question demeure : comment obtenir la liste initiale des Posts déjà diffusés, lorsqu'un client se connecte pour le première fois ?
C'est là qu'intervient la seconde souscription, à /app/channel.messages, apparaissant dans le code JavaScript ; en réponse à celle-ci, la partie serveur (la méthode de contrôleur retrievePosts) renvoie la liste des Posts sauvegardés, et dans la fonction JavaScript qui traite cette réponse, on peut donc initialiser la timeline avec une série d'appels à addPost.

Pour la partie serveur, la méthode retrievePosts du contrôleur TimelineController se charge de renvoyer la liste des Posts stockés en base de données :

1
2
3
4
5
6
7
@SubscribeMapping("/channel.messages")
protected def retrievePosts() {
    def posts
    Post.withNewSession { session ->
        posts = (Post.listOrderByDate() as JSON).toString()
    }
}

L'annotation org.springframework.messaging.simp.annotation.SubscribeMapping sur la méthode retrievePosts permet simplement d'indiquer que celle-ci doit être appelée lors de la souscription d'un client à /app/channel.messages. L'implémentation de retrievePosts consiste donc à retourner la représentation JSON de la liste des Posts, sous la forme d'une chaîne de caractères.

Quelques remarques concernant retrievePosts :

  • la méthode retrievePosts est marquée protected, et n'est donc pas exposée en tant qu'action ;
  • on recourt à la puissance de GORM pour obtenir la liste des objets persistés Post, en appelant la méthode statique synthétisée listOrderByDate ;
  • la conversion des éléments Post de la collection retournée par listOrderByDate est simplifiée par l'application du convertisseur JSON (avec l'opérateur Groovy as) ;
  • il est nécessaire de passer par une nouvelle session Hibernate par l'appel à la méthode statique Post.withNewSession, tandis que le code qui fait appel à GORM est encapsulé dans la Closure Groovy passée à cette méthode.

Ressources

Pour aller plus loin, je vous renvoie à la documentation de Spring, mais aussi au chapitre Messaging with WebSocket and STOMP du livre Spring in Action, Fourth Edition.
Par ailleurs, le projet spring-websocket-chat est aussi une grande source d'inspiration !