Dans notre article sur la mise en place d’un Batch Spring, j’ai évoqué la possibilité de visualiser la progression d’exécution du Batch en utilisant le protocole WebSocket. Celui-ci permet d’amorcer un canal de communication bi-directionnel entre votre page web et le serveur via un socket TCP. L’avantage d’une telle connexion est de permettre au serveur d’émettre des notifications vers le client Web sans recevoir au préalable une requête de la part du client.

Dans cet article nous allons voir comment mettre en place, émettre et réceptionner un message émis par notre serveur Java pour permettre la lecture en temps réel depuis une application Web sous Angular.

Spring propose bien entendu, une solution à l’utilisation de WebSocket. Vous devez d’abord injecter les dépendances suivantes dans votre projet Maven:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-messaging</artifactId>
</dependency>

Configuration sous Spring Boot

Dans un premier temps, vous devez définir une classe de configuration héritant de WebSocketMessageBrokerConfigurer, cela permet à Spring Boot d’orienter les appelles utilisant le protocole WebSocket vers les endpoints dédiés de votre application Java.

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
 
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer {
 
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
        registry.enableSimpleBroker("/topic");
    }
 
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/stomp").setAllowedOrigins("*");
    }
}

3 notions ici :

  • setApplicationDestinationPrefixes: définit le préfixe d’accès aux éventuels contrôleurs que les clients pourront consommer dans votre API.
  • enableSimpleBroker: définit le préfixe d’accès au flux émis par le broker pour les clients souhaitant s’y inscrire.
  • addEndpoint: définit le point d’entrée pour le handshake entre le client et le serveur. Cela permet d’établir la connexion ouverte entre les deux services. Remarquez le présence de setAllowedOrigins("*") permettant de gérer les CORS lors de l’appel.

Contrairement aux API de type REST, les API WebSocket ne permettent pas de passer d’arguments supplémentaires dans les headers de la requête ce qui devient compliqué pour les autorisations. De ce fait, il devient nécessaire d’ajouter un filtre supplémentaire à notre configuration de sécurité Spring :

@Override
public void configure(HttpSecurity http) throws Exception {
    // @formatter:off
    http
        .cors()
            .and()
        .headers()
            .frameOptions().disable()
            .and()
        .csrf().disable()
        .authorizeRequests()
            .antMatchers("/stomp").permitAll() // On autorise l'appel handshake entre le client et le serveur
            .anyRequest()
                .authenticated();
    // @formatter:on
}

Il est maintenant possible d’émettre un appelle de n’importe où depuis notre API auquel les clients peuvent souscrire, il suffit d’ajouter la commande suivante dans notre code Java :

messagingTemplate.convertAndSend("/topic/progress", "Hello world");

Le client qui a souscrit au message en provenance du endpoint « topic/progress » de notre API recevra la chaîne « Hello world ».

Dans l’exemple de projet fournit en bas de cet article j’ai ajouté, par commodité, l’envoi d’une notification dans un CommandLineRunner situé dans le point d’entrée du projet Spring Boot. Ici, j’émet toutes les trois secondes deux valeurs que je pousse ensuite vers le client.

/**
* Generate random numbers publish with WebSocket protocol each 3 seconds.
* @return a command line runner.
*/
@Bean
public CommandLineRunner websocketDemo() {
    return (args) -> {
        while (true) {
            try {
                Thread.sleep(3*1000); // Each 3 sec.
                progress.put("num1", randomWithRange(0, 100));
                progress.put("num2", randomWithRange(0, 100));
                messagingTemplate.convertAndSend("/topic/progress", this.progress);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    };
}

Installation des WebSocket sous Angular

Vous avez besoin de deux composants dans votre application Angular pour utiliser les WebSocket de manière simplifiée :

npm i @stomp/ng2-stompjs --save
npm i @types/sockjs-client --save

Le premier composant est une librairie vous permettant de vous connecter à un Broker STOMP via le protocole WebSocket. C’est une surcharge du projet @stomp/stompjs permettant une meilleur compatibilité avec les projets Angular 6+. Le deuxième composant permet simplement d’injecter les définitions de type du client SockJs. Celui-ci est utilisé par @stomp/ng2-stompjs

Vous devez ensuite fournir une initialisation du service depuis votre application Angular dans les providers du module.

import { RxStompService  } from '@stomp/ng2-stompjs';
 
@NgModule({
[...]
providers: [    
    RxStompService
  ],
[...]
})

WebSocket Service

Nous avons maintenant besoin d’un service générique dédié à l’utilisation de WebSocket que l’on pourrait positionner ainsi dans notre projet Angular : src/app/services/websocket.service.ts. Celui-ci prends en paramètre 3 éléments:

  • Une injection du service RxStompService permettant l’initialisation d’accès au Broker de messagerie.
  • Une éventuelle configuration du service RxStompService via l’interface InjectableRxStompConfig.
  • Une classe contenant d’éventuelles options supplémentaires, dont notamment le endpoint du broker.
import { InjectableRxStompConfig, RxStompService  } from '@stomp/ng2-stompjs';
import { Observable } from 'rxjs';
import { SocketResponse, WebSocketOptions } from '../models';

/**
 * A WebSocket service allowing subscription to a broker.
 */
export class WebSocketService {
  private obsStompConnection: Observable<any>;
  private subscribers: Array<any> = [];
  private subscriberIndex = 0;
  private stompConfig: InjectableRxStompConfig = {
    heartbeatIncoming: 0,
    heartbeatOutgoing: 20000,
    reconnectDelay: 10000,
    debug: (str) => { console.log(str); }
  };

  constructor(
    private stompService: RxStompService,
    private updatedStompConfig: InjectableRxStompConfig,
    private options: WebSocketOptions
    ) {
    // Update StompJs configuration.
    this.stompConfig = {...this.stompConfig, ...this.updatedStompConfig};
    // Initialise a list of possible subscribers.
    this.createObservableSocket();
    // Activate subscription to broker.
    this.connect();
  }

  private createObservableSocket = () => {
    this.obsStompConnection = new Observable(observer => {
      const subscriberIndex = this.subscriberIndex++;
      this.addToSubscribers({ index: subscriberIndex, observer });
      return () => {
        this.removeFromSubscribers(subscriberIndex);
      };
    });
  }

  private addToSubscribers = subscriber => {
    this.subscribers.push(subscriber);
  }

  private removeFromSubscribers = index => {
    for (let i = 0; i < this.subscribers.length; i++) {
      if (i === index) {
        this.subscribers.splice(i, 1);
        break;
      }
    }
  }

  /**
   * Connect and activate the client to the broker.
   */
  private connect = () => {
    this.stompService.stompClient.configure(this.stompConfig);
    this.stompService.stompClient.onConnect = this.onSocketConnect;
    this.stompService.stompClient.onStompError = this.onSocketError;
    this.stompService.stompClient.activate();
  }

  /**
   * On each connect / reconnect, we subscribe all broker clients.
   */
  private onSocketConnect = frame => {
    this.stompService.stompClient.subscribe(this.options.brokerEndpoint, this.socketListener);
  }

  private onSocketError = errorMsg => {
    console.log('Broker reported error: ' + errorMsg);

    const response: SocketResponse = {
      type: 'ERROR',
      message: errorMsg
    };

    this.subscribers.forEach(subscriber => {
      subscriber.observer.error(response);
    });
  }

  private socketListener = frame => {
    this.subscribers.forEach(subscriber => {
      subscriber.observer.next(this.getMessage(frame));
    });
  }

  private getMessage = data => {
    const response: SocketResponse = {
      type: 'SUCCESS',
      message: JSON.parse(data.body)
    };
    return response;
  }

  /**
   * Return an observable containing a subscribers list to the broker.
   */
  public getObservable = () => {
    return this.obsStompConnection;
  }
}

Ce service active à son initialisation un observable, createObservableSocket(), regroupant la liste des éventuelles souscriptions au broker permettant l’envoi simultané à l’ensemble des souscrits des mises à jour du message émis par le serveur.

Le message émis peut ensuite être lu de n’importe où de la manière suivante :

const obs = this.progressWebsocketService.getObservable();
obs.subscribe({
  next: this.onNewProgressMsg,
  error: (err) => { console.log(err); }
});

Progress WebSocket Service

progressWebsocketService est une classe héritant de WebSocketService qui nous permet d’instancier un WebSocket personnalisé.

import { Injectable } from '@angular/core';
import { InjectableRxStompConfig, RxStompService } from '@stomp/ng2-stompjs';
import { WebSocketService } from '../websocket.service';
import { WebSocketOptions } from '../../models';
 
export const progressStompConfig: InjectableRxStompConfig = {
  webSocketFactory: () => {
    return new WebSocket('ws://localhost:8080/stomp');
  }
};
 
@Injectable()
export class ProgressWebsocketService extends WebSocketService {
  constructor(stompService: RxStompService) {
    super(stompService, progressStompConfig, new WebSocketOptions('/topic/progress'));
  }
}

Notre client va souscrire au endpoint « /topic/progress » de notre serveur pour écouter les notifications émises en temps réel. On remarque également l’initialisation de l’objet WebSocket permettant l’utilisation du protocole avec le endpoint « ws://localhost:8080/stomp » correspondant au handshake entre le client et le serveur. Cela permet d’établir une connexion ouverte entre eux.

Pensez à rajouter votre service dans les providers de votre module Angular :

import { RxStompService  } from '@stomp/ng2-stompjs';
import { ProgressWebsocketService } from './services/progress.websocket.service';
 
@NgModule({
[...]
providers: [
    ProgressWebsocketService,
    RxStompService
  ],
[...]
})

Conclusion

Nous avons vu dans cet article comment:

  • Paramétrer notre serveur Java Spring Boot pour intégrer les WebSockets et émettre une notification.
  • Ajouter un service générique utilisant le protocole WebSocket sous Angular
  • Personnaliser un service pouvant souscrire à une notification serveur spécifique.

L’ensemble des sources du projet peut être téléchargé ici : websocket-with-angular.zip

Une répo GitHub est également disponible à cette adresse : Websocket-With-Angular

Références


12 commentaires

Cyrille Perrot · 1 avril 2020 à 10 h 09 min

L’état de l’art veut que tu utilises le corps du message pour émettre la notification. Le header ne doit être utilisé que si tu souhaites ajouter des meta données.

dylan deleplanque · 31 mars 2020 à 18 h 34 min

De mon côté, je l’ai récupéré dans le header de la SocketResponse. Est-ce une bonne pratique ?

dylan deleplanque · 31 mars 2020 à 18 h 28 min

Cela fonctionne en mettant une liste de WebSocketOptions.
Quel est la best practice pour récupérer la destination lorsque l’on fait le subscribe afin d’adapter le comportement en fonction de si la destination si c’est un Create ou Update ?

dylan deleplanque · 31 mars 2020 à 17 h 46 min

Tu veux dire changer le paramètre du constructeur de la class WebSocketService ?
Au lieu d’avoir un WebSocketOptions, avoir une liste de WebSocketOptions ?

Cyrille Perrot · 31 mars 2020 à 17 h 18 min

Même en ajoutant plusieurs endpoint, chaque websocket fonctionne de façon indépendante.

Heureusement 🙂

seul la dernière webSocket (updateTakeAwayStateWebSocketService) fonctionne.

Il faudrait que je vois comment tu as structuré tes deux services. Mais dans les faits, as tu réellement besoin d’avoir deux websockets distincts pour ce que tu veux faire ? Je veux dire, à partir du moment que tu as établi un handshake avec l’API, il te suffit d’avoir plusieurs brokers et d’écouter le broker dédié à la création et l’autre à la mise à jour (Ou je n’ai pas exactement compris ce que tu veux faire).

dylan deleplanque · 31 mars 2020 à 15 h 20 min

Bonjour,

Même en ajoutant plusieurs endpoint, chaque websocket fonctionne de façon indépendante.

Par contre, côté front, si dans mon composant, je déclare plusieurs webSocketService :

constructor(private createTakeAwayWebsocketService: CreateTakeAwayWebsocketService, private updateTakeAwayStateWebSocketService: UpdateTakeAwayStateWebsocketService) {
}

seul la dernière webSocket (updateTakeAwayStateWebSocketService) fonctionne.

Cyrille Perrot · 30 mars 2020 à 17 h 58 min

Normalement oui, dans notre cas on est sur du STOMP messaging. Si tu regardes l’implémentation du registry.addEndpoint situé dans la méthode registerStompEndpoints, tu remarqueras qu’il prends en paramètre un String varargs. Il est donc possible de définir plusieurs endpoints.

dylan deleplanque · 30 mars 2020 à 17 h 34 min

Bonjour Cyrille,

J’avais une petite question, est-il possible de déclarer deux webSocket dans un même constructeur. J’ai essayé, mais seul le dernier fonctionne.

Dans le cas où c’est impossible, peut-on utiliser deux webSocket dans un même composant ?

Merci 🙂

Cyrille Perrot · 27 mars 2020 à 11 h 03 min

Bonjour Dylan ! Oui effectivement je suis allé un peu vite sur l’envoi d’une notif. Je vais suivre ton conseil.

dylan deleplanque · 26 mars 2020 à 19 h 13 min

Bonjour Cyrille,

Très bon tuto, le seul moment auquel j’ai dû aller regarder le code source c’est pour savoir où place le: « messagingTemplate.convertAndSend »,

Le tuto serait parfait si tu ajoutes ton controller. Mais sinon très très très bien.

Merci beaucoup, j’avais jamais essayer de mettre en place des websocket et j’ai pû le faire en quelques minutes.

Cyrille Perrot · 16 mars 2020 à 8 h 44 min

Merci, n’hésitez pas à me dire si vous avez eu des difficultés avec certaines parties de l’article ou si vous avez des remarques.

Abousalih · 13 mars 2020 à 20 h 52 min

Merci bcp,
Exemple trés trés simple à implémenter, Chapeau !!!!

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.