Utilisation des WebSockets sous Angular avec Java Spring Boot

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 :

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.

Un projet GitHub est disponible pour consulter l’ensemble du code source : websocket-with-angular

Références

One Reply to “Utilisation des WebSockets sous Angular avec Java Spring Boot”

  1. Si vous rencontrez le problème suivant :

    [Argument of type ‘InjectableRxStompConfig’ is not assignable to parameter of type ‘StompConfig’.
    Types of property ‘beforeConnect’ are incompatible.
    Type ‘(client: RxStomp) => void | Promise’ is not assignable to type ‘() => void | Promise’.ts(2345)]
    

    Cela vient d’une mise à jour réalisée par l’auteur du composant stomp-js/rx-stomp. J’ai ouvert une issue sur son compte GitHub : https://github.com/stomp-js/rx-stomp/issues/207

    En attendant vous pouvez contourner le problème de cette manière en surchargeant la classe InjectableRxStompConfig avec la config suivante :

    import { InjectableRxStompConfig } from '@stomp/ng2-stompjs';
    
    /**
     * Fix annoying TS2345 error when injecting InjectableRxStompConfig into
     * RxStomp.stompClient.configure method who don't need the rxStomp
     * configuration.
     */
    export class FixedStompConfig extends InjectableRxStompConfig {
      constructor() {
        super();
      }
    
      beforeConnect?: () => void | Promise;
    }
    

    Il vous suffira ensuite de remplacer tous les appels à InjectableRxStompConfig avec FixedStompConfig.

Laisser un commentaire

Votre adresse e-mail 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.

Utilisation des WebSockets sous Angular avec Java Spring Boot

par Cyrille Perrot Temps de lecture: 5 min
1