04 - Observer et EventEmitter : reagir aux changements
Ce que tu vas apprendre
- Le pattern Observer et sa version Node.js (EventEmitter)
- Comment typer les events proprement en TypeScript
- Les pièges : memory leaks et erreurs silencieuses
Prerequisites
Avoir lu l'introduction de la serie.
Le problème
Une commande est créée. Il faut envoyer un email de confirmation, mettre à jour le stock, notifier l'équipe sur Slack, et enregistrer une ligne d'analytics. Le reflexe du junior :
typescriptasync function createOrder(data: OrderData): Promise<Order> {
const order = await orderRepo.save(data);
await sendConfirmationEmail(order); // mail
await updateInventory(order); // stock
await notifySlack(order); // slack
await trackAnalytics("order_created", order); // analytics
return order;
}
Quatre dépendances directes. createOrder connaît le système mail, le stock, Slack et l'analytics. Ajouter un cinquieme effet de bord ? Tu modifies createOrder. Le service Slack est en panne ? La commande echoue. Tester createOrder demande de mocker quatre services.
La solution : emettre un événement
Au lieu d'appeler directement chaque service, createOrder emet un événement. Ceux qui veulent reagir s'abonnent.
typescriptimport { EventEmitter } from "events";
const bus = new EventEmitter();
// Chaque service s'abonne independamment
bus.on("order:created", (order: Order) => sendConfirmationEmail(order));
bus.on("order:created", (order: Order) => updateInventory(order));
bus.on("order:created", (order: Order) => notifySlack(order));
bus.on("order:created", (order: Order) => trackAnalytics("order_created", order));
// createOrder ne connait plus personne
async function createOrder(data: OrderData): Promise<Order> {
const order = await orderRepo.save(data);
bus.emit("order:created", order);
return order;
}
createOrder ne sait pas qui écoûte. Ajouter un cinquieme listener ? Un bus.on de plus, zero modification de createOrder. Slack est en panne ? Le listener Slack echoue, les autres continuent. Le découplage est total.
Typer les events (parce que `any`, non merci)
Le problème d'EventEmitter natif : tout est any. Tu peux emettre "order:craeted" (avec une typo) et personne ne te previent. La solution :
typescripttype AppEvents = {
"order:created": [Order];
"order:cancelled": [Order, string]; // order + raison
"user:registered": [User];
"payment:failed": [Order, Error];
};
class TypedEmitter extends EventEmitter {
emit<K extends keyof AppEvents>(event: K, ...args: AppEvents[K]): boolean {
return super.emit(event, ...args);
}
on<K extends keyof AppEvents>(event: K, listener: (...args: AppEvents[K]) => void): this {
return super.on(event, listener as any);
}
off<K extends keyof AppEvents>(event: K, listener: (...args: AppEvents[K]) => void): this {
return super.off(event, listener as any);
}
}
const bus = new TypedEmitter();
// TypeScript connait le type du parametre
bus.on("order:created", (order) => {
// order est type Order, pas any
console.log(order.id);
});
// Erreur de compilation : "order:craeted" n'existe pas
// bus.emit("order:craeted", someOrder);
Ce wrapper de 20 lignes t'évité une categorie entière de bugs. Tu peux aussi utiliser la lib eventemitter3 qui offre des generics natifs, ou mitt qui est encore plus legere.
Observer classique vs EventEmitter
Le pattern Observer du GoF definit un Subject qui maintient une liste d'Observers. En TypeScript, l'EventEmitter fait exactement ca, mais avec des noms d'events au lieu de classes Observer. La différence est surtout syntaxique.
Si tu veux l'approche classique sans EventEmitter :
typescripttype Listener<T> = (data: T) => void;
class Observable<T> {
private listeners: Listener<T>[] = [];
subscribe(listener: Listener<T>): () => void {
this.listeners.push(listener);
// Retourne une fonction de desabonnement
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
notify(data: T): void {
for (const listener of this.listeners) {
listener(data);
}
}
}
// Usage
const orderEvents = new Observable<Order>();
const unsub = orderEvents.subscribe((order) => console.log("New order:", order.id));
orderEvents.notify(newOrder);
unsub(); // se desabonner
L'avantage de cette version : le subscribe retourne une fonction de cleanup. Tu n'as pas besoin de garder une référencé au listener pour te desabonner. C'est un pattern qu'on retrouve dans React (le cleanup des useEffect) et dans RxJS.
L'auto-tagging RSS de paltemps.fr
Sur paltemps.fr, quand un nouveau flux RSS est ajoute, le système analyse le contenu et attribue des tags automatiquement. Ca ressemble a un Observer : l'ajout d'un article déclenché une reaction (le tagging) sans que le module RSS ne connaisse le module de tagging.
typescriptbus.on("rss:article_added", async (article: RssArticle) => {
const tags = await analyzeContent(article.title, article.summary);
await articleRepo.updateTags(article.id, tags);
});
Un listener, une responsabilité. Si demain on veut aussi générer un résumé IA de chaque article, on ajoute un listener. Le module RSS reste inchange.
Les pièges
Erreurs silencieuses. Si un listener throw, par défaut EventEmitter emet un event "error" non attrape et crash le process. Ou pire, si tu catches l'erreur dans le listener, l'emetteur ne sait pas que quelque chose a echoue. Si l'email de confirmation DOIT partir (requirement métier), ne le mets pas dans un event. Appelle-le directement.
typescript// Si le mail DOIT partir, pas d'event :
async function createOrder(data: OrderData): Promise<Order> {
const order = await orderRepo.save(data);
await sendConfirmationEmail(order); // obligatoire, appel direct
bus.emit("order:created", order); // le reste est optionnel
return order;
}
Memory leaks. Chaque bus.on(...) ajoute un listener. Si tu t'abonnes dans une boucle ou dans un handler de requête sans te desabonner, tu accumules des listeners. Node affiche un warning a 11 listeners par event. Écoûte ce warning.
typescript// MAUVAIS : un nouveau listener a chaque requete
app.get("/orders", (req, res) => {
bus.on("order:created", (order) => { /* ... */ }); // leak !
});
Ordre d'exécution. Les listeners sont appeles dans l'ordre d'enregistrement, de facon synchrone par défaut. Si un listener est async, l'emetteur n'attend pas la fin. bus.emit retourne immédiatement. Si tu as besoin d'attendre, c'est un signe que tu ne devrais pas utiliser un event.
Résumé
- Observer/EventEmitter découplé l'emetteur des recepteurs
- Type tes events pour éviter les typos et les
any - Utilise des events pour les effets de bord optionnels, pas pour les opérations obligatoires
- Attention aux memory leaks (listeners jamais retires)
Article précédent : 03 - Strategy
Article suivant : 05 - Adapter : faire parler deux interfaces incompatibles