I quattro operatori che vedete nel titolo trattano tutti la stessa problematica: la concatenazione di Observable. Io li chiamo scherzosamente la sacra quaterna perché rappresentano un po’ la cassetta degli attrezzi (ovvero l’indispensabile) per gli sviluppatori che hanno a che fare con RxJS e stream di dati complessi tutti i giorni.
Capita spesso che, durante una mia consulenza, noti l’utilizzo di un operatore sbagliato (intendo logicamente sbagliato, non scritto male), e la risposta che mi viene data solitamente è: “Sì, ma se uso un altro operatore non funziona“. Segue poi la mia domanda “Ok, e questo operatore non ti ha mai dato problemi?” a cui solitamente segue la risposta “Ogni tanto qualche bug c’è, ma non riusciamo a capire come mai“.
Se vi è capitato di trovarvi in una situazione simile, questo articolo è per voi: ecco la guida completa su perché, come e quando utilizzare mergeMap, switchMap, concatMap e exhaustMap.
Indice dei contenuti
Observable in parallelo con mergeMap
Ecco il tipico caso d’uso: abbiamo un’azione dell’utente che deve scatenare una chiamata http.
import { fromEvent, ajax } from 'rxjs';
import { map } from 'rxjs/operators';
// Observable di partenza
const userActions$ = fromEvent(myForm, 'submit');
userActions$.pipe(
// Al posto di ajax, se usate Angular, userete il servizio HttpClient
map(action => ajax('[...]'))
).subscribe(result => {
// ???
});
Se l’azione dell’utente proviene da un Observable e se utilizziamo un Observable (o una Promise!) per scatenare la chiamata http, utilizzando l’operatore map incorriamo in un problema comune: la generazione di un Higher Order Observable. Quello che riceviamo nel subscribe non è il risultato della chiamata, ma l’Observable stesso (e la chiamata non viene eseguita): in pratica abbiamo un Observable che emette degli Observable.
Non hai familiarità con la dicitura “Higher Order”? Fermati qualche minuto e leggi questo articolo!
La prima cosa che potrebbe venirvi in mente è di effettuare un Subscribe all’interno dell’operatore map e ritornare manualmente i vari valori:
userActions$.pipe(
map(action => {
return ajax('[...]').subscribe(...);
})
).subscribe(result => ...);
Ma in questo caso ritorneremmo una Subscription, e non vogliamo nemmeno quello. Non vogliamo nemmeno effettuare il subscribe all’interno dell’ultimo subscribe, altrimenti perderemmo completamente i vantaggi più grandi di RxJS e degli operatori.
Quello che dobbiamo fare è usare mergeMap: questo operatore esegue automaticamente un subscribe nascosto sull’Observable che ritorniamo, e ci pensa lui a ri-emettere i risultati nella nostra catena di operatori.
import { mergeMap } from 'rxjs/operators';
userActions$.pipe(
mergeMap(action => ajax('[...]'))
).subscribe(result => /* result è il risultato della chiamata! */);
Questo meccanismo è detto in gergo Flattening, ovvero appiattimento. Perché questo nome particolare? Perché se ci immaginiamo un diagramma Marble in cui ogni Observable è una linea temporale, l’Observable principale sarà una linea orizzontale, mentre gli Observable che inseriamo nel mergeMap (nel nostro caso ajax) vanno rappresentati da linee oblique per non sormontarsi al principale: l’operatore mergeMap le appiattisce tutte rendendoci tutti questi ulteriori Observable praticamente invisibili agli occhi del subscribe, il quale non ha idea di cosa sia successo nella catena di operatori.
Come ultima cosa, diamo un po’ di nomi: l’Observable che piazziamo all’interno del mergeMap è detto anche Inner Observable, ovvero Observable Interno. Va da sé che, in questo caso, l’Observable principale (userActions$) venga chiamato Outer Observable.
Inner vs Outer Observable
Questa distinzione può sembrare superflua ma in realtà è molto importante: sappiamo che, in una catena di operatori, ogni operatore genera un nuovo Observable dietro le quinte. Ma ai nostri occhi, è come se ci fosse un unico Observable e un unico flusso di dati. Infatti, se proviamo a intrometterci in qualsiasi punto della catena con un operatore “bloccante”, ad esempio first(), il flusso di dati da quell’operatore in poi si stopperà inesorabilmente.
source$.pipe(
debounceTime(...),
map(...),
filter(...),
first(), // Questo operatore farà completare l'Observable alla prima emissione!
map(...),
...
);
Quando invece abbiamo a che fare con un Inner Observable, si tratta di un Observable separato con il suo “flusso personale”. Se utilizziamo l’operatore first() all’interno del mergeMap infatti, sarà solamente quell’Observable a completare, non il nostro Outer Observable (il principale), che rimarrà vivo.
userActions$.pipe(
mergeMap(action => ajax('[...]').pipe(
first()
))
).subscribe(result => /* result è il risultato della chiamata! */);
In questo caso comunque, trattandosi di una chiamata http, quell’Inner Observable avrebbe comunque completato con la prima emissione, ma questo è solo un esempio molto semplice.
Ora arrivano i guai, perché introduciamo un concetto fondamentale in RxJS: il tempo.
Il problema della Concorrenza
Il caso che abbiamo appena visto, quello di una chiamata http, è un caso molto comune ma con molte sfaccettature. Si tratta anche di un caso abbastanza semplice, perché se analizziamo i due Observable separatamente vediamo che:
- L’Observable sorgente (le azioni dell’utente) può scatenare molte emissioni (ad esempio se l’utente tocca nuovamente un form).
- L’Inner Observable (ajax) è una semplice chiamata http, che scatena 1 emissione e poi completa.
Il nostro Inner Observable è quindi molto semplice, ma possiamo già incorrere in qualche problema: cosa succederebbe se, mentre la chiamata http è in corso, l’Observable sorgente emettesse un nuovo valore? Ad esempio, supponiamo che l’azione dell’utente sia la ricerca tramite un elemento input, se l’utente si accorgesse di aver sbagliato a digitare e subito dopo essersi corretto facesse ripartire la ricerca, che fine farebbe la “vecchia” chiamata http?
Nel nostro caso, mergeMap non si interessa dell’ordine: se arrivasse una nuova azione dell’utente, scatenerebbe una seconda chiamata ma non farebbe nulla per modificare o stoppare la chiamata precedente. Possiamo dire che è un vantaggio? In alcuni casi sì, nel nostro decisamente no: non ci interessa più la chiamata vecchia se l’utente si è corretto o ha cambiato idea. Ma non è sempre così! A fine articolo faremo un recap sui casi in cui mergeMap può esserci molto utile.
Avremmo anche un ulteriore problema con mergeMap: ok, la chiamata vecchia viene mantenuta, e quindi? Se subito dopo arriva il risultato della nuova chiamata siamo a posto, no? Invece no, perché nulla ci garantisce che la prima chiamata finisca prima della seconda. Ci sono molte variabili in gioco, il server potrebbe essere più sotto stress in un certo momento o magari la prima richiesta dell’utente richiedeva dei calcoli più complessi. Quindi, non sarebbe strano se ricevessimo i risultati delle chiamate http in ordine sbagliato: mergeMap non fa nulla per proteggerci.
Ricordiamoci che per l’Observer (nel nostro caso, la callback dentro il subscribe) tutti questi passaggi sono nascosti ed è suo compito rimanere ignorante: non può essere lui a decidere se la chiamata http è ancora valida o se scartarla. Il processo deve essere totalmente trasparente per lui, ed è per questo che mergeMap ha dei “fratelli” che si comportano in maniera leggermente differente.
Il nostro esempio ha quindi bisogno di una rinfrescata perché, come abbiamo detto, mergeMap non risolve il nostro problema specifico: abbiamo bisogno di switchMap.
Annullare l’Observable precedente con switchMap
Questo operatore è forse addirittura più famoso di mergeMap, perché è usatissimo proprio per il genere di scenario che abbiamo appena descritto: ci serve un operatore che si comporti esattamente come mergeMap ma che, una volta che l’Outer Observable emette un nuovo valore, faccia terminare l’Inner Observable, stoppandolo.
import { switchMap } from 'rxjs/operators';
userActions$.pipe(
switchMap(action => ajax('[...]'))
).subscribe(result => ...);
Ora possiamo dire che siamo a posto, il nostro codice funzionerà perfettamente se lo scenario è quello che abbiamo descritto. Ma attenzione: l’operatore switchMap non ci protegge da tutti i rischi! O meglio, risolve i rischi di cui abbiamo parlato nel capitolo precedente, ma ne porta di nuovi.
Ad esempio, è vero che la chiamata http viene stoppata e il nostro Observer non ne riceverà più il risultato, ma la chiamata è stata fatta comunque ed è arrivata fino al server. Quindi, se si è trattato semplicemente di una richiesta di lettura, poco male, ma se la nostra chiamata fosse stata una POST, PUT, PATCH o DELETE… qualche danno potremmo averlo fatto. Potremmo ad esempio aver cancellato un’entità nel database ma per la nostra UI l’entità potrebbe ancora esistere, creando inconsistenze fra front-end e back-end.
Quindi, ve lo anticipo già: switchMap è utilissimo per le operazioni di lettura.
Ma ci sono altri 2 casi di concorrenza che potremmo voler gestire, e quindi altri 2 fratelli di mergeMap! Ora parliamo un attimo di concatMap.
Mettere in coda l’Observable con concatMap
Anche questo operatore è molto simile a mergeMap ma agisce in maniera un po’ diversa: nel caso l’Outer Observable emetta prima che l’Inner Observable sia terminato, concatMap mette la successiva richiesta in coda.
Quando potrebbe essere utile un comportamento del genere? Beh, ad esempio nel caso di uno riordinamento con drag&drop. Se spostiamo rapidamente molti elementi di una lista nella nostra UI, vogliamo mantenere l’ordine consistente sul back-end e quindi tutte le operazioni di riordinamento dovranno esser fatte in sequenza. Abbiamo già visto che mergeMap non ci proteggerebbe ma non lo farebbe nemmeno switchMap, perché rischieremmo di “annullare” uno spostamento precedente. Nel nostro caso gli spostamenti ci servono tutti e ci servono tutti in ordine.
C’è una cosa a cui dovete fare attenzione quando utilizzate concatMap: se un Inner Observable non termina, non passerà mai a quello successivo. Detta in parole povere, se una chiamata http ci mettesse 20 secondi a completarsi, si creerebbe una coda molto lunga. O peggio ancora, se l’Inner Observable non fosse una chiamata http ma un Observable più complesso, il disastro potrebbe essere dietro l’angolo. Quindi usatelo con prudenza!
Siamo quindi arrivati all’ultimo operatore della lista: exhaustMap. Come dite? Ha un nome strano? Sì, tanto quanto il suo funzionamento!
“Non mi disturbare” con exhaustMap
Questo operatore gestisce l’ultimo caso rimasto di concorrenza: nel caso l’Outer Observable emetta prima che l’Inner Observable sia terminato, exhaustMap se ne frega e continua a fare quello che stava facendo. E anche quando l’Inner Observable avrà completato, non riprenderà in mano gli eventi messi in coda, perché non li mette nemmeno in coda: li butta proprio via, come non fossero mai esistiti.
Non ho scelto a caso il titolo di questo capitolo: il modo più facile per ricordarsi come funziona questo operatore è di raffigurarlo come un operatore scorbutico, che non vuole essere disturbato. Ti serve un’altra richiesta http? Vai via, non ne voglio sapere, non ho ancora finito. E ti dà uno schiaffo, pure.
Strano? Sì, ma a volte serve. E adesso facciamo un riepilogo vedendo i vari casi d’uso di questi operatori!
Conclusioni e casi d’uso
Abbiamo visto che:
- Questi 4 operatori di Flattening rappresentano la cassetta degli attrezzi per chi ha a che fare con stream di dati complessi, con Observable concatenati, in cascata o in parallelo.
- Ognuno ha una sua particolarità su come gestisce la concorrenza, ma tutti fanno una cosa comune: eseguono un subscribe nascosto sull’Inner Observable e ci restituiscono i valori di soppiatto, in maniera trasparente per l’Observer.
Ecco i vari casi in cui potrebbero esservi utili questi operatori:
- switchMap: generalmente per operazioni di lettura, dove l’annullamento di un’operazione precedente non causa nessun problema. Può essere usato anche per operazioni di modifica a patto che la modifica sia sempre della stessa entità. Non è il caso ad esempio di un effetto Redux/NgRx dove le azioni possono essere generiche (stessa azione per tutte le entità di un certo tipo, es. “[Customer] Update”) o addirittura globali se si utilizza qualcosa come @ngrx/data (es. “[Entity] Update”). Questo operatore è anche molto utile quando non si parla di chiamate http: è l’ideale ad esempio per far scattare dei timer o mostrare delle notifiche e nascondere le precedenti!
- mergeMap: generalmente per operazioni di scrittura (create, update, delete) e tipicamente in effetti Redux dove le azioni sono generiche (non volete scartare la modifica a un’entità se arriva un’azione di modifica di un’altra entità). In poche parole, in tutti i casi dove non è importante l’ordine di arrivo, ma è importante che nessuna Observable venga scartato.
- concatMap: abbiamo già fatto l’esempio del re-order, e possiamo estenderlo a tutti i casi in cui vogliamo essere sicuri che nessun Observable venga scartato, ma ci serve mantenere l’ordine di arrivo. Di nuovo, attenzione agli Inner Observable che non terminano!
- exhaustMap: quando non volete che un’ulteriore emissione disturbi quella corrente. Quindi, dovete aver presente che questo operatore, proprio come switchMap, può causare delle emissioni scartate! Il caso d’uso per eccellenza è in un form di login: è inutile riprovare a loggarsi se il server deve ancora dirci se la password che abbiamo inserito era giusta. Al contrario, il server potrebbe arrabbiarsi e, a seconda dei tentativi sbagliati, potrebbe bloccarci l’account!
Piccola nota: come avete visto, mergeMap è quello che offre meno “protezione” fra tutti: se siamo obbligati ad utilizzarlo (ad esempio in un effetto Redux) ma vogliamo essere sicuri che l’utente non combini disastri mentre una chiamata è in corso, è buona prassi mostrare uno spinner all’utente o addirittura bloccargli la UI finché la chiamata non è terminata. E questa soluzione la potete estendere anche agli altri operatori: è vero che ci proteggono, ma l’utente non lo sa, e se un tasto per lui rimane cliccabile, non può immaginare che la sua azione non abbia avuto effetto perché gliel’ha bloccata un exhaustMap, non vi pare? 🙂
Divertitevi e, mi raccomando: ora non avete più scuse per utilizzare l’operatore sbagliato!