- "Old school" :
- Polling
- Long polling
- Depuis HTML5 :
- WebSocket
- Server Sent events
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...
Serveur
Client
temps
temps
Handshake (HTTP / GET)
WebSocket upgrade
Ouverture de la connexion TCP
Communication bidirectionnelle
Fermeture de la connexion
Le serveur pousse une même information à tous les clients connectés...
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>
(...)
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());
}
}
}
}
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.
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());
}
}
}
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.
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.
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);
}
}
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);
});
}
}
Chaque client connecté choisi la valeur qu'il veut suivre...
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");
}
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());
}
}
}
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);
}
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);
}
La démo s'appuie sur une API publique qui ne peut être interrogée trop fréquemment sous peine d'être banni...
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
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"}
Sur la base de l'exemple précédent...
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("*");
}
}
l'annotation @EnableWebSocket est remplacée par
l'annotation @EnableWebSocketMessageBroker.
Cette annotation permet d'utiliser le protocole STOMP par dessus le protocole WebSocket.
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.
La dernière partie de la configuration consiste à ajouter un endpoint pour les messages entrants.
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()));
}
}
1/ Suppression du composant AppWebSocket.
2/ Création du service Stomp :
2.1/ Ajout de la dépendance stompjs :
yarn add stompjs
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();
}
}
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.
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));
}
}
3/ Modification du composant
Souscription
Communication ascendante
- utilisation de HTTP
- communication descendante (du serveur au(x) client(s)) uniquement
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
Reconnect
Le serveur pousse une même information à tous les clients connectés...
Il est possible de partir d'un simple projet avec la dépendance Web via Spring Initializr.
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;
}
}
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);
}
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;
}
}
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);
}
};
});
}
}
2/ Utilisation dans le composant :
(...)
import { SseService } from './sse.service';
@Component({
(...)
})
export class AppComponent implements OnInit {
ticker: Ticker;
tickers: Ticker[] = [];
constructor(private __sseService: SseService) {
}
}
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 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
- ...
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>
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);
}
(...)
}
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()));
});
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)...
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...
Les sources sont disponibles ici.