Du back au front : les server-sent events

Le 11/02/2022 Par Victor COLEAUjavajavascriptspringbootangularwebsocket

Communiquer des informations entre le front-end et le back-end d'une application web est monnaie courante, mais bien souvent ces échanges sont unidirectionnels : le front requête une information via une API REST ou GraphQL, et le back répond.

Comment faire si c'est le Backend qui a une information à communiquer ? Comment la transmettre au client ?

Une première solution serait les WebSockets, ces canaux bidirectionnels bien pratiques.

Dans le cas où vous ne souhaitez pas ouvrir de WebSockets, où votre application ne supporterait pas ce protocole ou si vous souhaitez simplement rester sur un protocole http, une autre solution est possible : les Server Send Events.

Server Sent Events : définition

Les Server Sent Events sont donc une technologie basée uniquement sur le protocole http grâce à laquelle le back-end d'une application web peut envoyer de son initiative des informations aux différents clients.

Contrairement aux WebSockets qui sont bidirectionnels, les Server Sent Events sont unidirectionnels : seul le back peut émettre dessus.

Actuellement cette technologie est supportée par les navigateurs web suivants :

  • Google Chrome
  • Mozilla Firefox
  • Opera
  • Safari
  • Microsoft Edge

Seul Internet Explorer ne la supporte pas (comme c'est bizarre).

Implémentation des Server Sent Events

Pour les exemples qui suivent, le back-end sera rédigé en Java Spring Boot et le front-end en JavaScript Angular.

Coté back-end : Émetteur et émission

Initialisation

Avant de pouvoir communiquer avec le front-end, ce dernier doit s'inscrire auprès du back-end comme souhaitant recevoir des événements. Pour ce faire, votre application serveur doit mettre à disposition un endpoint bien spécifique qui ouvrira la connexion.

@RestController  
@RequestMapping("api/v1/sse")  
public class SseController {

	@GetMapping("/subscribe")
	public SseEmitter subscribe() {
		return new SseEmitter(600000L);
	}
}

Décortiquons un peu ce code.

Il s'agit tout d'abord d'un Controller assez standard dans l'environnement Spring Boot. On y voit deux annotations : @RestController signifiant bien que l'on a affaire à un Controller et @RequestMapping indiquant la base du chemin des endpoints (ici api/v1/sse).

Le plus important vient ensuite : la méthode subscribe. D'abord, une annotation, @GetMapping, indiquant qu'il s'agit d'un endpoint http de type GET mais aussi son chemin. Cette méthode sera donc accessible au path api/v1/sse/subscribe. Que fait cette méthode ? Elle retourne tout simplement un objet de type SseEmitter

Cet objet est natif Spring Boot. Comme son nom l'indique, il s'agit un émetteur qui nous servira par le suite à envoyer nos messages aux clients. Une dernière chose, le nombre passé en paramètre du constructeur, bien qu'optionnel, est très pratique puisqu'il permet de définir la durée de vie de l'émetteur. Ici 600 000 millisecondes font 10 minutes. C'est personnellement la valeur que j'utilise car elle n'est ni trop petite, ce qui provoquerait des reconnexions intempestives, ni trop élevée, ce qui garderait en mémoire des objets inutilisés dès lors que l'utilisateur a quitté l'application.

Premier message

Bien, maintenant que notre émetteur est initialisé nous pouvons commencer à émettre dessus. En réalité, c'est nécessaire puisque si aucun message n'est présent dès l'ouverture de la connexion, celle-ci sera considérée invalide et instantanément fermée par le client.

Développons donc un peu plus notre code de tout à l'heure :

@RestController  
@RequestMapping("api/v1/sse")  
public class SseController {

	private SseEmitter emitter;
	private Long lastId = 0;

	@GetMapping("/subscribe")
	public SseEmitter subscribe() {
		this.emitter = new SseEmitter(600000L);
		this.emitter.send(SseEmitter.event()
			        .name("message")
			        .id("" + lastId++)
			        .data("connexion"));
		return this.emitter;
	}
}

Alors, qu'est-ce qui a changé ?

Tout d'abord le SseEmitter initialisé est stocké dans un attribut du Controller, ceci afin de pouvoir le réutiliser par la suite pour envoyer d'autres messages.

Puis, dans la corps de la méthode, avant de retourner notre objet, un premier message y est écrit grâce à la méthode send. Cette méthode prend en paramètre un objet de type SseEventBuilder, construit à partir de la méthode statique event(). Trois informations sont ensuite ajoutées à l'événement :

  • name : type d'événements. Seules deux valeurs sont possibles : message (pour les événements normaux) et error (pour les événements d'erreurs). Attention, les événements d'erreur ferment la connexion.
  • id : identifiant unique à chaque message permettant de les traquer (utilisé aussi dans le cas de reconnexion).
  • data : données envoyées.

Garder la connexion en vie

À partir d'ici vous devriez être capable de mettre en place une connexion server-sent Events entre votre front et votre back, et d'y envoyer un premier message. (Voir la partie front-end qui suit si vous souhaitez tout de suite ouvrir le flux)

Mais je dois vous parler d'un autre point avant de pouvoir continuer sereinement. Par défaut, coté Front, une connexion SSE ne reste ouverte que quelques secondes. Pour la maintenir en vie, le Backend doit donc envoyer régulièrement des événements sur le flux. Nous les appellerons Heartbeats.

Retournons donc à nouveau dans le code de notre Controller :

@RestController  
@RequestMapping("api/v1/sse")  
public class SseController {

	private SseEmitter emitter;
	private Long lastId = 0;

	@GetMapping("/subscribe")
	public SseEmitter subscribe() {
		this.emitter = new SseEmitter(600000L);
		this.emitter.send(SseEmitter.event()
			        .name("message")
			        .id("" + lastId++)
			        .data("connexion"));
		return this.emitter;
	}
	
	@Scheduled(fixedRate = 30000)  
	public void heartbeat() {
		this.emitter.send(SseEmitter.event()
					.name("message")
					.id("" + ++lastId)
					.data("heartbeat"));
	}

}

Une méthode heartbeat() a été rajoutée. Annotée de @Scheduled(fixedRate = 30000), celle-ci sera répétée toutes les 30 secondes. Son corps a une forme connue puisqu'elle ne fait qu'envoyer un événement de type message et contenant un simple champs de texte. Je rappelle que ces événements n'ont pour but que de maintenir en vie la connexion et pourront être complétement ignorés par le Frontend.

Toujours plus d'événements

Maintenant que notre connexion est stable, nous pouvons nous servir du flux.

Tant que l'objet SseEmitter est en vie, vous pourrez vous en servir un nombre illimité de fois pour transmettre des informations au Frontend. La seule chose à modifier est le contenu des messages, par la méthode data() vue plus haut.

Le code ci-dessous n'est qu'un exemple, a vous de définir une structure de données qui vous convient pour standardiser vos messages.

@RestController  
@RequestMapping("api/v1/sse")  
public class SseController {

	private SseEmitter emitter;
	private Long lastId = 0;

	@GetMapping("/subscribe")
	public SseEmitter subscribe() {
		this.emitter = new SseEmitter(600000L);
		this.emitter.send(SseEmitter.event()
			        .name("message")
			        .id("" + lastId++)
			        .data("connexion"));
		return this.emitter;
	}
	
	@Scheduled(fixedRate = 30000)  
	public void heartbeat() {
		this.emitter.send(SseEmitter.event()
					.name("message")
					.id("" + ++lastId)
					.data("heartbeat"));
	}

	private void sendMessage(Object data) {
		this.emitter.send(SseEmitter.event()
					.name("message")
					.id("" + ++lastId)
					.data(object));
	}

}

Encore un dernier point

Deux dernières petites info avant de passer au client.

Premièrement, dans l'exemple donné ici, le code n'est adapté qu'a 1 utilisateur. Pour pouvoir gérer plusieurs utilisateurs il vous faudra faire les changements nécessaires : garder en mémoire plusieurs SseEmitter (dans une liste par exemple au lieu d'un attribut simple), identifier quels utilisateurs se sont inscrits (via un id unique par utilisateur par exemple), différencier quels messages devront être envoyés à quel utilisateur, etc.

Deuxièmement, à l'exception de la méthode du endpoint en lui-même, toute la logique de création, d'initialisation, de maintient en vie de la connexion et d'envoi de données peut être déplacée dans un service adéquat.

Coté front-end : Récepteur et interprétation

Ouvrir la connexion

Comme expliqué un peu plus haut, la première chose que le Frontend doit faire est de s'inscrire auprès du Backend comme souhaitant recevoir des mises à jour.

Pour cela, rien de plus simple, il suffit de requêter le endpoint précédemment créé grâce à l'objet Angular EventSource.

@Injectable({
	providedIn: 'root'
})
export class SseService {

	private sseEndpoint = 'localhost/api/v1/sse/subscribe';
	private eventSource: EventSource;

	constructor() {}

	public subscribe() {
		this.eventSource = new EventSource(this.sseEndpoint);
	}
}

Dans ce code assez court on voit plusieurs choses.

Tout d'abord il s'agit d'un service classique, injectable dans d'autres composants.

Mais surtout une méthode subscribe() qui initialise un objet EventSource à partir de l'url du endpoint (attention à bien faire correspondre cette url à la votre). Le constructeur du EventSource va automatiquement lancer la requête et intercepter la réponse. Tant que cet objet est en vie, la connexion le restera. De plus, si une erreur survient, le processus de reconnexion se lancera automatiquement.

Réception des événements

Bon, ouvrir un flux c'est bien, mais lire ce qui en sort, c'est mieux.

Pour cela, trois petites méthodes sont à définir comme suit :

@Injectable({
	providedIn: 'root'
})
export class SseService {

	private sseEndpoint = 'localhost/api/v1/sse/subscribe';
	private eventSource: EventSource;

	constructor() {}

	public subscribe() {
		this.eventSource = new EventSource(this.sseEndpoint);

		this.eventSource.onopen = ((ev) => console.log(ev));
		this.eventSource.onerror = (ev => {
			console.log(ev);
			return null;
		});
		this.eventSource.onmessage = ((ev) => {
			if(ev.data = 'heartbeat') {
				console.log('heartbeat a ignorer');
			} else {
				console.log('autre événement a traiter')
			}
		});
	}
}

Quelques explications :

  • onopen() : réagit aux événement de type open. Ces événements sont générés par l'ouverture de la connexion. Ici on écrit dans la console l'événement uniquement à titre indicatif, aucune action spécifique n'est préconisée.
  • onerror() : réagit aux événement de type error. Globalement, ceux-ci sont a ignorer puisque l'auto-reconnexion prendra le relai. Pour l'exemple on ne fait qu'afficher l'erreur. De plus, vous pouvez voir qu'on retourne en fin de méthode la valeur null. Ceci permet n'ignorer l'affiche normalement automatique de certaines erreurs. Ce n'est pas obligatoire mais votre console de log risque d'être vite envahie si vous ne le faite pas.
  • onmessage() : réagit aux événement de type message. Tous les événements normaux seront de ce type. C'est dans cette méthode que devront concrètement être interprétés les messages envoyés par le Backend. (Ne pas oublier que même avec ce type, tous les événements ne sont pas toujours pertinent, notamment les heartbeats.)

Aller plus loin

Si vous êtes rendu jusqu'ici, vous savez maintenant mettre en place les server-sent events aussi bien coté serveur que coté client.

Mais vous avez peut-être remarqué un détail manquant : le header. En effet, nativement, EventSource ne permet pas de passer un header à la requête initialisant la connexion. Et c'est bien dommage puisque parfois celui-ci sera obligatoire, comme dans le cas d'une application nécessitant d'être authentifié.

Pour palier à ce problème, une petite librairie supplémentaire est nécessaire.

Commencez par l'installer :

npm install event-source-polyfill

Une fois cette commande exécutée vous devriez voir la librairie event-source-polyfill dans votre fichier package.json.

Ainsi vous aurez accès à l'objet EventSourcePolyfill depuis votre code. Remplacez simplement l'ancien EventSource par le nouveau EventSourcePolyfill, vous pourrez alors lui passer en second paramètre de son constructeur un header.

@Injectable({
	providedIn: 'root'
})
export class SseService {

	private sseEndpoint = 'localhost/api/v1/sse/subscribe';
	private eventSource: EventSource;

	constructor() {}

	public subscribe() {
		this.eventSource = new EventSourcePolyfill(this.sseEndpoint, {
			header: {
				heartbeatTimeout: 600000,
				'authorization': 'token',
				...
			}
		});

		this.eventSource.onopen = ((ev) => console.log(ev));
		this.eventSource.onerror = (ev => null);
		this.eventSource.onmessage = ((ev) => {
			if(ev.data = 'heartbeat') {
				console.log('heartbeat a ignorer');
			} else {
				console.log('autre événement a traiter')
			}
		});
	}
}

Notez le paramètre heartbeatTimeout qui définit, cette fois-ci côté front, le temps espéré entre deux heartbeats.

Conclusion

Pour conclure, les server-sent events, bien que techniquement moins permissifs que les WebSockets, de par leur prise en charge native par bon nombre d'acteurs du web, sont une excellente solution pour rendre votre application encore plus réactive. Leur facilité d'implémentation et leur efficacité permettront une intégration simple et rapide pour dynamiser tous vos projets.

À vous de jouer !

Sommaire

  • fleche vers la droite Server Sent Events : définition
  • fleche vers la droite Implémentation des Server Sent Events
  •         fleche vers la droite Coté back-end : Émetteur et émission
  •         fleche vers la droite Coté front-end : Récepteur et interprétation
  • fleche vers la droite Conclusion

À voir aussi

Tous les articles