REAL TIME WEB Applications

 

WebSocket

Server SENT Events

Spring WebFlux

 

RAPPEL(s)

- "Old school" :

    - Polling

    - Long polling

 

- Depuis HTML5 :

    - WebSocket

    - Server Sent events

OBJECTIF

Intégration de données en temps réel dans vos applications, par exemple : 

   - IoT

   - gaming (scores...)

   - etc.

 

En particulier dans nos applications : 

   - changement de version du backend --> rechargement explicite à faire côté frontend

   - outils collaboratifs : chat bots...

   - modification concurrente de données...

 

WebSocket

 

Références

 

Démos & How-to's

 

Fonctionnement

 

Serveur

Client

temps

temps

Handshake (HTTP / GET)

WebSocket upgrade

Ouverture de la connexion TCP

Communication bidirectionnelle

Fermeture de la connexion

Un exemple

Le serveur pousse une même information à tous les clients connectés...

Backend...

Ajouter la dépendance Websocket via Spring Initializr ou en modifiant le fichier pom.xml d'un projet existant (extrait du fichier pom.xml) :

        (...)
        <dependencies>
                (...)
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>
		(...)
	</dependencies>
        (...)

Un exemple

Backend...

Fonctionnement côté serveur :

@Component
public class TickerWebSocketHandler extends TextWebSocketHandler {

	private List<WebSocketSession> sessions = new CopyOnWriteArrayList<WebSocketSession>();

	@Override
	public void afterConnectionEstablished(WebSocketSession session) {
		LOGGER.info("Connection established for session #" + session.getId());
		this.sessions.add(session);
	}

	public void sendTicker(String ticker) {
		if (sessions != null && !sessions.isEmpty()) {
			try {
				TextMessage msg = new TextMessage(gson.toJson(ticker));
				for (WebSocketSession session : sessions) {
					if (session.isOpen()) {
						session.sendMessage(msg);
					}
				}
			} catch (Exception e) {
				LOGGER.error(e.getMessage());
			}
		}
	}
}

Un exemple

Backend...

Fonctionnement côté serveur :

	private List<WebSocketSession> sessions = new CopyOnWriteArrayList<WebSocketSession>();

Permet de gérer les Websockets ouverts par l'ensemble des clients.

        @Override
	public void afterConnectionEstablished(WebSocketSession session) {
		LOGGER.info("Connection established for session #" + session.getId());
		this.sessions.add(session);
	}

Permet de gérer un pool de Websockets par ajout de chaque client qui se connecte.

Un exemple

Backend...

Fonctionnement côté serveur :

Méthode d'envoi du message à l'ensemble des clients connectés.

	public void sendTicker(String ticker) {
		if (sessions != null && !sessions.isEmpty()) {
			try {
				TextMessage msg = new TextMessage(gson.toJson(ticker));
				for (WebSocketSession session : sessions) {
					if (session.isOpen()) {
						session.sendMessage(msg);
					}
				}
			} catch (Exception e) {
				LOGGER.error(e.getMessage());
			}
		}
	}

Un exemple

Backend...

Configuration du Websocket :

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

	@Autowired
	private TickerWebSocketHandler lTickerWebSocketHandler;

	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {

		registry.addHandler(lTickerWebSocketHandler, "/ws/ticker");

	}

}

Associe un endpoint à la classe qui gère son comportement.

Un exemple

Backend...

Utilisation du Websocket par le service :

@Service
public class TickerService {

	@Autowired
	private TickerWebSocketHandler lTickerWebSocketHandler;

        (...)
	
	public String getTickerValue() {
		
		(...)
		
		return numberFormat.format(currentTicker);
		
	}

	@Scheduled(fixedRate = 3000)
	private void sendTicker() {
		
		lTickerWebSocketHandler.sendTicker(this.getTickerValue());
		
	}
}

Le service est enrichi d'une méthode qui envoie la donnée sur le Websocket.

Un exemple

FRONTEND...

Composant permettant d'ouvrir un Websocket :

@Injectable();
export class AppWebSocket {

    private socket: Subject<MessageEvent>;

    public connect(url: string): Subject<MessageEvent> {
        if (!this.socket) {
            this.socket = this.create(url);
        }
        return this.socket;
    }
        
    private create(url): Subject<MessageEvent> {
        const ws = new WebSocket(url);
        const observable = Observable.create(
            (obs: Observer<MessageEvent>) => {
                ws.onmessage = obs.next.bind(obs);
                ws.onerror = obs.error.bind(obs);
                ws.onclose = obs.complete.bind(obs);
                return ws.close.bind(ws);
            }
        );
        const observer = {
            next: (data: Object) => {
                if (ws.readyState === WebSocket.OPEN) {
                    ws.send(JSON.stringify(data));
                }
            },
        };
        return Subject.create(observer, observable);
    }
}

Un exemple

FRONTEND...

Utilisation :

export class AppComponent implements OnInit {

  private appWebSocket = new AppWebSocket();
  private appWebSocketUrl = 'ws://vbl-dva-xubuntu15.dev.gnc:8080/ws/ticker';
  ticker: string;

  ngOnInit() {

    this.appWebSocket.connect(this.appWebSocketUrl).asObservable().subscribe(message => {
      console.log(message.data);
      this.ticker = JSON.parse(message.data);
    });

  }
}

Un deuxieme exemple

communication bidirectionnelle

Chaque client connecté choisi la valeur qu'il veut suivre...

Backend...

Il faut modifier la gestion de la session de chaque utilisateur pour conserver la valeur observée :

public class TickerWebSocketHandler extends TextWebSocketHandler {

  private Map<WebSocketSession, String> sessions = new HashMap<WebSocketSession, String>();

  @Override
  public void afterConnectionEstablished(WebSocketSession session) {
    this.sessions.put(session, "USDT_BTC");
  }

  

communication bidirectionnelle

Backend...

L'envoi des valeurs est également modifié :

@Scheduled(fixedRate = 20000)
  public void sendTicker() {
    if (sessions != null && !sessions.isEmpty()) {
      try {
        Iterator<Entry<WebSocketSession, String>> it = sessions.entrySet().iterator();
        while (it.hasNext()) {
          Entry<WebSocketSession, String> entry = it.next();
          if (entry.getKey().isOpen()) {
            entry.getKey()
                 .sendMessage(new TextMessage(gson.toJson(lTickerService.getTickerValue(entry.getValue()))));
          } else {        
            sessions.remove(entry.getKey());         
          }
        }
      } catch (Exception e) {
        LOGGER.error(e.getMessage());
      }
    }
  }

communication bidirectionnelle

Backend...

Et il faut gérer la valeur surveillée pour chaque session :

  @Override
  protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {

    this.sessions.replace(session, message.getPayload());

  }

Le service a été modifié pour renvoyer les informations relatives à une valeur particulière :

public String getTickerValue(String currencyPair) {

    (...)
		
    return numberFormat.format(currentTicker);

  }

communication bidirectionnelle

FRONTEND...

Modification du fonctionnement du composant et utilisation de la méthode send pour la communication ascendante :

ngOnInit() {

    this.__http.get('http://vbl-dva-xubuntu15.dev.gnc:8080/api/ticker/list').subscribe((response: Response) => {
      this.currencyPairList = JSON.parse(response.text());
      this.currencyPair = this.currencyPairList[0];
      this.sendMessage(JSON.stringify(this.currencyPair));
    });

    this.appWebSocket.connect(this.appWebSocketUrl).asObservable().subscribe(message => {
      this.ticker = JSON.parse(message.data);
    });

  }

  onChange(newCurrencyPair: string) {
    this.sendMessage(newCurrencyPair);
  }

  sendMessage(msg: string) {
    this.appWebSocket.send(msg);
  }

Disclaimer

La démo s'appuie sur une API publique qui ne peut être interrogée trop fréquemment sous peine d'être banni...

STOMP over WebSocket

STOMP over WebSocket

STOMP signifie the Simple (or Streaming) Text Oriented Messaging Protocol

C'est un protocole d'échange de messages textuels sur le modèle producteur / consommateur et qui s'appuie sur un 'message broker' (par défaut, en mémoire, mais pourrait être n'importe quelle implémentation supportant le protocole STOMP ( RabbitMQ, ActiveMQ...).

 

Les messages sont toujours sous la forme générique :

COMMAND
header1: value1
header2: value2
...

Body

STOMP over WebSocket

Exemples :

CONNECT
accept-version:1.1,1.0
heart-beat:10000,10000
CONNECTED
version:1.1
heart-beat:0,0
SUBSCRIBE
id:sub-0
destination:/topic/ticker
SEND
destination:/ws/ticker
content-length:25

{"inputField":"USDT_BTC"}
MESSAGE
destination:/topic/ticker
content-type:application/json;charset=UTF-8
subscription:sub-0
message-id:9-6
content-length:31

{"outputField":"8151.80399997"}

STOMP over WebSocket - EXEMPLE

Sur la base de l'exemple précédent...

Backend...

1/ Suppression de la classe TickerWebSocketHandler

2/ Modification de la classe WebSocketConfig :

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.enableSimpleBroker("/topic");
		registry.setApplicationDestinationPrefixes("/ws");
	}

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/ws/ticker").setAllowedOrigins("*");

	}
}

STOMP over WebSocket - EXEMPLE

Backend...

l'annotation @EnableWebSocket est remplacée par

l'annotation @EnableWebSocketMessageBroker.

 

Cette annotation permet d'utiliser le protocole STOMP par dessus le protocole WebSocket.

 

STOMP over WebSocket - EXEMPLE

Backend...

Le message broker est configuré via la surcharge de la méthode configureMessageBroker.

Ici, je l'utilise en mémoire (enableSimpleBroker) ; pour se connecter sur un message broker d'entreprise, il faudrait utiliser la méthode enableStompBrokerRelay.

 

Dans cette configuration, tout ce qui est envoyé sur un endpoint "/topic" sera transporté par le message broker ; tout ce qui est envoyé sur un endpoint "/ws" pourra être traité par une méthode annotée @MessageMapping, typiquement un @Controller, comme pour vos services REST.

STOMP over WebSocket - EXEMPLE

Backend...

La dernière partie de la configuration consiste à ajouter un endpoint pour les messages entrants.

STOMP over WebSocket - EXEMPLE

Backend...

Interception des messages entrants par modification du @Controller :

@RestController
public class TickerController {

	(...)

	@MessageMapping("/ticker")
	@SendTo("/topic/ticker")
	public OutputMessage getTicker(InputMessage inputMessage) throws Exception {

		return new OutputMessage(lTickerService.getTickerValue(inputMessage.getInputField()));

	}

}

STOMP over WebSocket - EXEMPLE

FRONTEND...

1/ Suppression du composant AppWebSocket.

2/ Création du service Stomp :

        2.1/ Ajout de la dépendance stompjs :

yarn add stompjs

STOMP over WebSocket - EXEMPLE

FRONTEND...

        2.2/ Création du service :

import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { Observable } from 'rxjs/Observable';

import 'stompjs';

declare let Stomp: any;

@Injectable()
export class StompService {

    private _stompClient;
    private _stompSubject: Subject<any> = new Subject<any>();

    public connect(_webSocketUrl: string): void {
        const self = this;
        const webSocket = new WebSocket(_webSocketUrl);
        this._stompClient = Stomp.over(webSocket);
        this._stompClient.connect({}, function (frame) {
            self._stompClient.subscribe('/topic/ticker', function (stompResponse) {
                // stompResponse = {command, headers, body with JSON
                // reflecting the object returned by Spring framework}
                const tickerResponse = JSON.parse(stompResponse.body);
                self._stompSubject.next(tickerResponse['outputField']);
            });
        });
    }

    public send(_payload: string) {
        this._stompClient.send('/ws/ticker', {}, JSON.stringify({ 'inputField': _payload }));
    }

    public getObservable(): Observable<any> {
        return this._stompSubject.asObservable();
    }
}

STOMP over WebSocket - EXEMPLE

FRONTEND...

        Création d'un Subject qui va donc être Observable.

        Méthode de connection dans laquelle on crée un client Stomp par dessus un WebSocket pour s'y connecter. On souscrit aux messages diffusés sur le Message Broker et on les affecte au Subject lorsqu'on les reçoit.

       "Observation" du Subject précédemment créé 

       Communication ascendante sur le WebSocket via le client Stomp.

STOMP over WebSocket - EXEMPLE

FRONTEND...

3/ Modification du composant

(...)
import { StompService } from './stomp/stomp.service';

@Component({
  selector: 'app-root',
})
export class AppComponent implements OnInit {

  (...)
  constructor(private __http: Http, private __stompService: StompService){}

  ngOnInit(): void {

    this.currencyPair = new CurrencyPair('USDT_BTC');

    this.__stompService.getObservable().subscribe(response => {
      this.ticker = response;
    });

    (...)

  }

  onChange(newCurrencyPair: string) {
    this.sendMessage(JSON.stringify(newCurrencyPair));
  }

  sendMessage(msg: string) {
    this.__stompService.send(JSON.parse(msg));
  }

}

STOMP over WebSocket - EXEMPLE

FRONTEND...

3/ Modification du composant

Souscription

Communication ascendante

SERVER SENT EVENTS

SERVER SENT EVENTS

- utilisation de HTTP

- communication descendante (du serveur au(x) client(s)) uniquement

SERVER SENT EVENTS

Serveur

Client

temps

temps

HTTP request (GET / POST)

Event

HTTP response

Event

HTTP response

state = CLOSED

HTTP request (GET / POST)

Event

HTTP response

Event

HTTP response

state = CLOSED

Fonctionnement

Reconnect

Un exemple

Le serveur pousse une même information à tous les clients connectés...

Backend...

Il est possible de partir d'un simple projet avec la dépendance Web via Spring Initializr.

Un exemple

Backend...

Le @Controller :

@RestController
public class TickerController {
	
	private final CopyOnWriteArrayList<SseEmitter> lSseEmitters = new CopyOnWriteArrayList<>();
	
	 @GetMapping(path = "/api/tickers")
	 public SseEmitter handleClientSseRequest() {
		 SseEmitter lSseEmitter = new SseEmitter();
		 this.lSseEmitters.add(lSseEmitter);
		 lSseEmitter.onCompletion(() -> this.lSseEmitters.remove(lSseEmitter));
		 lSseEmitter.onTimeout(() -> this.lSseEmitters.remove(lSseEmitter));
		 return lSseEmitter;
	 }
}

Un exemple

Backend...

Le @Controller (suite et fin) :

	 @EventListener()
	 public void onTickersUpdate(TickerSimpleModel pTicker) {
		 
		 List<SseEmitter> unreachableEmitters = new ArrayList<>();
		 
		 this.lSseEmitters.forEach(emitter -> {
			 try {
				 emitter.send(pTicker);
			 } catch (IOException ioee) {
				 unreachableEmitters.add(emitter);
			 }
		 });
		 
		 this.lSseEmitters.removeAll(unreachableEmitters);
		 
	 }

Un exemple

Backend...

Le @Service :

@Service
public class TickerService {

	public final ApplicationEventPublisher lApplicationEventPublisher;

	public TickerService(ApplicationEventPublisher pApplicationEventPublisher) {
		this.lApplicationEventPublisher = pApplicationEventPublisher;
	}

	@Scheduled(fixedRate = 20000)
	private Map<String, PoloniexTickerModel> getTickersValues() {

		(...)

		try {
			map = mapper.readValue(json, new TypeReference<HashMap<String, PoloniexTickerModel>>() {});
			map.entrySet().stream().forEach(entry -> {
				lApplicationEventPublisher.publishEvent(new TickerSimpleModel(entry.getKey(), entry.getValue()));
			});
		} catch (IOException e) {
			LOGGER.error("Publish failed !", e);
		}
		return map;
	}
}

UN EXEMPLE

FRONTEND...

1/ Création du service SSE :

import { Injectable } from '@angular/core';
import * as EventSource from 'eventsource';
import { Observable } from 'rxjs/Observable';


@Injectable()
export class SseService {

    private __EventSource: EventSource;

    public connect(url: string): Observable<EventSource> {
        this.__EventSource = new EventSource(url);
        return Observable.create(observable => {
            this.__EventSource.onmessage = message => observable.next(message.data);
            this.__EventSource.onerror = error => {
                /*
                Reconnect when EventSource.readyState === 2 (CLOSED)
                */
                if (this.__EventSource.readyState === 2) {
                    this.connect(url);
                }
            };
        });

    }

}

UN EXEMPLE

FRONTEND...

2/ Utilisation dans le composant :

(...)
import { SseService } from './sse.service';

@Component({
  (...)
})
export class AppComponent implements OnInit {

  ticker: Ticker;
  tickers: Ticker[] = [];

  constructor(private __sseService: SseService) {
  }

}

UN EXEMPLE

FRONTEND...

2/ Utilisation dans le composant (suite et fin) :

ngOnInit(): void {
    this.__sseService.connect('http://vbl-dva-xubuntu15.dev.gnc:8080/api/tickers').subscribe({
      next: message => {
        this.ticker = JSON.parse(message);
        let updated = false;
        for (let i = 0; i < this.tickers.length; i++) {
          const tickerTemp = this.tickers[i];
          if (tickerTemp.currencyPair === this.ticker.currencyPair) {
            this.ticker.change = this.ticker.lastTicker < tickerTemp.lastTicker ? '-' : '+';
            this.tickers[i] = this.ticker;
            updated = true;
          }
        }
        if (!updated) {
          this.tickers.push(this.ticker);
        }
      },
      error: err => {
        console.error('something wrong occurred: ');
        console.error(err);
      }
    });
  }

Spring WebFlux

Project REACTOR

Spring WebFlux

Spring 5

Reactive & functional programming

Reactive de bout en bout ?

 

Attention à la couche d'accès aux données

(opérations synchrones & bloquantes) !

    - Drivers alternatifs (               par exemple)

    - JPA 2.2

    - ...

Spring WebFlux

Un exemple

Backend...

Initialisation du projet avec la dépendance spring-boot-starter-webflux :

<?xml version="1.0" encoding="UTF-8"?>
<project (...)>
	
        (...)

	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.0.0.M6</version>
		<relativePath /> <!-- lookup parent from repository -->
	</parent>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-webflux</artifactId>
		</dependency>

                (...)

        </dependencies>

Spring WebFlux

Un exemple

Backend...

Le @Controller :

@RestController
public class TickerController {

        private final CopyOnWriteArrayList<TickerSimpleModel> tickers = new CopyOnWriteArrayList<>();

	@GetMapping(path = "/api/tickers", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
	public Flux<TickerSimpleModel> getTickers() {

		Flux<TickerSimpleModel> result = Flux.fromStream(tickers.stream());

		Flux<Long> duration = Flux.interval(Duration.ofMillis(50));

		return Flux.zip(result, duration).map(Tuple2::getT1);

	}

	(...)

}

Spring WebFlux

Un exemple

Backend...

Le @Service :

@Service
public class TickerService {

	public final ApplicationEventPublisher lApplicationEventPublisher;

	public TickerService(ApplicationEventPublisher pApplicationEventPublisher) {
		this.lApplicationEventPublisher = pApplicationEventPublisher;
	}

	@Scheduled(fixedRate = 3000)
	public void getTickersValues() {

		while (Boolean.TRUE) {

                        // cf. ce qui a été fait dans les exemples précédents...

			try {
				map = mapper.readValue(json, new TypeReference<HashMap<String, PoloniexTickerModel>>() {
				});

				map.entrySet().stream().forEach(entry -> {
					lApplicationEventPublisher.publishEvent(new TickerSimpleModel(entry.getKey(), entry.getValue()));
				});

Spring WebFlux

Un exemple

FRONTEND...

Rien à changer !

POUR CONCLURE

POUR CONCLURE

HTTP TCP
WebSocket
SSE
spring-webflux

Pour les WebSockets, il faut faire notamment attention à la configuration de vos proxies qui doivent explicitement autoriser le champ "Upgrade" dans les headers de la première requête (GET)...

Protocoles

POUR CONCLURE

Les éléments à retenir sont :

  - WebSocket : protocole TCP / Communications bidirectionnelles

  - SSE : protocole HTTP / Communications descendantes

 

Le choix d'une solution doit donc d'abord être fait en fonction du besoin...

 

Ce qu'il faut retenir :

  - WebSocket se soustrait aux contraintes du protocole HTTP

  - SSE dispose d'une meilleure optimisation de la taille du contenu transféré qui compense l'overhead lié aux entêtes HTTP

 

spring-webflux : pas assez de recul...

PERFORMANCES

POUR CONCLURE

Les sources sont disponibles ici.

Merci de votre attention

Question(s)

WebSocket / Server Sent Events / Spring-WebFlux (Spring 5)

By Didier Vanderstoken

WebSocket / Server Sent Events / Spring-WebFlux (Spring 5)

  • 1,075