Quasi ogni sviluppatore web trascorre la prima parte della sua carriera producendo software senza una vera e propria cognizione di cosa sia uno stato e di come gestirlo. Il motivo è, principalmente, che, molto spesso, framework e librerie si prendono cura di questo aspetto per noi.
Beh, potremmo dire che questo è un bene… lo è, vero?
La verità sta nel mezzo. Da un lato è sicuramente auspicabile che un framework degno di questo nome si prenda cura di gestire lo stato interno dell'applicazione; d'altra parte è evidente che, se passiamo anni a fare applicazioni senza la minima nozione di cosa sia una macchina a stati, una riduzione o un sistema di change detection, probabilmente non stiamo facendo molti progressi e sicuramente finiremo col generare complessità inutili, proprio perché la gestione dei nostri stati mancherà di una struttura architetturale consapevole.
Iniziamo dunque, come sempre, dalle definizioni:
Che cos'è uno stato?
Lo stato di un'applicazione è la condizione in cui questa si trova in un dato momento. Perché si parli di stato, le informazioni devono essere disponibili in modo più o meno persistente, ma non totalmente transitorio.
La domanda sorge spontanea: è necessario che un'applicazione abbia uno stato?
Assolutamente no, in teoria. In pratica, all'incirca sì.
Che noia le risposte ambigue, eh?
Proviamo ad argomentare.
Stateful vs stateless
A seconda del tipo di applicazione che stiamo sviluppando, seguiremo una definizione, più o meno rigorosa, di stato. Generalmente, le applicazioni stateful sono quelle in cui, in qualsiasi momento, viene messa a disposizione delle logiche una fotografia accessibile dei dati con cui l'applicazione sta lavorando.
Se ad esempio, nella nostra applicazione, sono presenti una serie di variabili più o meno globali, che vengono manipolate man mano che succedono cose e restano a disposizione nella loro versione più aggiornata, la nostra applicazione è stateful. La definizione va allentandosi se tale stato può essere messo a disposizione su richiesta, ad esempio interrogando un database oppure un servizio esterno. In tal caso, la gestione dello stato è comunque presente, ma è delegata a un componente software esterno, per cui, ad essere rigorosi, dobbiamo considerare la nostra applicazione stateless.
Volendo fare una brutale banalizzazione della realtà:
Tipo di applicazione | Stateful/Stateless |
Backend con database | Stateless, se si esclude il database |
Backend in-memory | Stateful |
Frontend client-side | Generalmente stateful |
Frontend server-side | Generalmente stateless |
Funzionalità singola (input -> output) | Stateless, se si escludono input e output (e generalmente si escludono) |
Lo stato è, solitamente, definito in contrapposizione alle logiche che lo manipolano; tali logiche sono spesso definite comportamento dell'applicazione.
Il comportamento
A questo punto, si pone un problema non da poco, su cui la comunità dei web developer si interroga sin dall'alba dei tempi: in che modo il comportamento dovrebbe leggere e/o modificare lo stato?
Bisogna dire che dalla risposta che diamo a questa domanda dipendono, concretamente, cose che potremo o non potremo fare e linguaggi di programmazione che potremo o non potremo utilizzare. Come dicevamo, una questione non banale.
Mutare lo stato
Se stiamo realizzando un editor di testo, è evidente che una parte importante dello stato della nostra applicazione è costituita dal testo che stiamo modificando.
Questo esempio, molto sofisticato, deriva dal fatto che, al momento, stiamo scrivendo un articolo utilizzando proprio un editor di testo.
Dunque, nel momento in cui digitiamo un nuovo carattere, è molto probabile che da qualche parte, nel componente editor della nostra applicazione, ci sia un campo text che viene aggiornato. Se stiamo realizzando un prodotto software un minimo sofisticato, è alquanto improbabile che useremo direttamente il valore del componente grafico che rappresenta l'editor; più probabilmente ci avvarremo di una qualche forma di data-binding, cioè di aggiornamento automatico della proprietà con cui le nostre logiche lavoreranno, ogni volta che l'elemento della UI viene aggiornato.
Ebbene, quanto appena descritto si definisce un approccio mutable, ed è la soluzione preferita in molte architetture a oggetti: ogni volta che l'utente fa qualcosa, la nostra proprietà editor.text viene aggiornata di conseguenza. Similarmente, se premiamo il tasto grassetto, la proprietà editor.bold verrà assegnata a true, etc…
- Il vantaggio di questo approccio è che la mappatura tra l'azione dell'utente e lo stato è direttissima e non richiede particolari logiche aggiuntive;
- Uno svantaggio è che, se dovessimo "incastrare" delle logiche particolarmente complicate nel momento in cui l'utente fa qualcosa, dovremmo tirare su un sofisticato labirinto di eventi e dovremmo, anche, fare attenzione a dove interveniamo: un evento potrebbe scattare prima o dopo che lo stato sia stato aggiornato;
- Un altro svantaggio è che, se dovessimo mai utilizzare un linguaggio che supporta il calcolo parallelo, potremmo sperimentare problemi legati alla concorrenza, cioè al fatto che diversi thread stanno intervenendo sullo stato, e potrebbero farlo in modo tale da rendere lo stato completamente inconsistente.
Sostituire lo stato
Un altro approccio, generalmente preferito in paradigmi di programmazione funzionale, è quello per cui, ad ogni evento corrisponde una sostituzione dello stato. Per la precisione, tale approccio è definito riduttivo, perché partendo da uno stato iniziale e aggiungendo una serie di trasformazioni (o transizioni), estrae lo stato finale, cioè riduce un elenco di azioni su uno stato iniziale ad uno stato finale (o meglio, allo stato corrente).
- Un vantaggio gigante di questo approccio è il supporto automatico al multi-threading. Qualsiasi componente stia agendo sullo stato produrrà una sua copia aggiornata dello stato. Se poi anche lo stato stesso è rappresentato come un evento, si parla di programmazione reattiva; a questo punto non è possibile per nessun componente conservare lo stato, ma solo trasformarlo e poi informare altri componenti;
- Un altro vantaggio di questo approccio è che rende possibile centralizzare la gestione di eventi eterogenei: ogni volta che succede qualcosa, una sola funzione (detta riduttore) viene chiamata con lo stato precedente e un parametro che indica "ciò che è successo". In questo modo, tutti gli eventi vengono gestiti come uno solo, recante al suo interno informazioni specifiche sulla transizione che lo stato dovrà subire;
- L'elefante nella stanza è un piccolo ma cruciale svantaggio: la memoria. Prendiamo l'esempio di prima ed immaginiamo, ad ogni pressione di un tasto sulla tastiera, di chiamare un riduttore, passando il contenuto del nostro editor più lo stato di tutti i pulsanti (grassetto, corsivo, etc…). Non solo. Ad ogni pressione, il contenuto del nostro stato non viene aggiornato ma completamente sostituito. Immaginate di fare questa cosa non con un editor di testo, ma con la registrazione di un video, è evidente che questo approccio non è sostenibile là dove l'elaborazione richiede un certo livello di ottimizzazione. La realtà dei fatti è che, nelle applicazioni di tutti i giorni, questo elefante in realtà è piccolo piccolo e non costituirà praticamente mai un problema.
Architetture e design pattern
Le seguenti architetture o design-pattern hanno principalmente due obiettivi:
- separare la presentazione (cioè le viste) dalle logiche;
- facilitare un approccio ordinato alla gestione dello stato dell'applicazione.
Model-View-Controller (MVC)
Questo pattern ha una certa età, ma non passa mai di moda; facilita un approccio orientato agli oggetti ed è composto dai seguenti strati:
- Model: rappresenta lo stato dell'applicazione o, per lo meno, di una parte di essa. Generalmente, è un oggetto passivo, con nessuna o pochissime logiche, mappa un database, o comunque è un qualcosa di memorizzabile o serializzabile;
- View: questo pattern è, anch'esso, uno strato relativamente passivo, rappresenta i dati e raccoglie le interazioni dell'utente;
- Controller: è il detentore del comportamento, genera la vista a seconda dei dati presenti nel model; al presentarsi di un evento, elabora il model (cioè manipola lo stato) e, poi, aggiorna la vista di conseguenza, rigenerandola.
Questo pattern ha il vantaggio di essere molto semplice. Può essere considerata una basilare forma di separazione tra stato (model), presentazione (view) e logiche (controller), ma tende a sovraccaricare di responsabilità il controller. Generalmente, è adottata in applicazioni in cui l'interazione tra vista e stato avviene in momenti ben scanditi. Un classico esempio è rappresentato dalle applicazioni web renderizzate lato server, dove il pattern MVC è, ad oggi, lo standard.
Model-View-ViewModel (MVVM)
Questa è un'architettura relativamente nuova ed è, principalmente, adottata in framework moderni, che prevedono un'interazione più fitta tra vista e modifiche allo stato. Come nell'MVC, vengono individuati tre strati, ma le responsabilità sono un pochino ripartite:
- Il model rappresenta sempre lo stato, è sempre un oggetto tendenzialmente passivo ma in questo caso può presentare qualche logica aggiuntiva in merito ad esempio alla manipolazione dei dati che contiene;
- Anche la view si assume qualche responsabilità in più, precisamente si preoccupa di creare dei data-binding a doppia via tra la presentazione e lo stato. In altre parole la view si preoccupa di mantenere aggiornato lo stato a seconda di ciò che l'utente fa;
- Il ViewModel è letteralmente il Model della View, cioè è un componente (generalmente una classe) che mappa tra ciò che c'è nella vista e quello che è lo stato interno dell'applicazione, eventualmente applicando tutte le logiche di comportamento (validazioni, salvataggi, caricamenti, etc…).
I framework MVVM presentano una qualche forma di change detection, cioè una strategia per capire quando lo stato dell'applicazione è cambiato e, dunque, la vista dev'essere aggiornata. Una strategia di change detection può essere quella di rigenerare la vista ogni volta che succede qualcosa, controllando se lo stato è cambiato; un'altra strategia molto comune (e decisamente più ottimizzata) è quella dell'observer pattern: i campi dello stato non vengono rappresentati come variabili, ma come flussi di eventi che vengono scatenati ogni volta che il valore viene aggiornato. In questo caso la view sa esattamente quale parte dello stato è cambiata e quindi procede ad aggiornarla.
MVC vs MVVM
Potrà esserti sembrato che tra MVC e MVVM cambi veramente poco, e in effetti è così. Le principali differenze sono:
- In un caso (MVC) il controller è l'elemento centrale e potrebbe essere responsabile di una o più viste, mentre gli altri strati sono passivi; nell'altro caso (MVVM) la vista assume una sua centralità, eventualmente "stringendo" data-binding con tutti i ViewModel necessari, in base a quante diverse aree dello stato vengono manipolate da tale vista.
- In un caso (MVC) la comunicazione tende ad essere ben scaglionata: ad esempio, nel compilare un form, il controller potrebbe non essere mai interpellato fino al momento della sottoscrizione, dopodiché la vista verrebbe completamente ricaricata da zero. Nell'altro caso (MVVM) la vista mantiene aggiornato il ViewModel e quindi anche il Model ad ogni azione dell'utente, e al momento della sottoscrizione non sarà necessario che il ViewModel riceva tutto il contenuto del form, perché ce l'ha già a disposizione, già aggiornato. Decisamente più efficace se si vuole avere un controllo più capillare tra ciò che l'utente fa e ciò che l'applicazione deve recepire ed elaborare.
Flux/Redux
Quest'architettura è probabilmente più snella delle due già citate, e non individua in modo rigido una "filiera" di lavorazione dei dati e degli eventi. Invece, si limita a servire lo stesso proposito nel modo più sintetico possibile. Individua i seguenti strati:
- Stato: rappresenta ciò che nei due pattern precedenti è il model. Qui lo stato è tendenzialmente un oggetto unico per tutta l'applicazione, internamente ripartito in tutte le componenti funzionali della stessa; lo stato è un oggetto decisamente passivo e non ha alcuna logica integrata;
- Azione: rappresenta una "cosa" che può "succedere". In altre parole, un evento, ma non nella forma normalmente intesa in programmazione. Un'azione potrebbe essere un oggetto passivo o una funzione che viene passata come parametro al riduttore;
- Riduttore: rappresenta una funzione centrale, che potrebbe per analogia corrispondere al controller che riceve come parametro lo stato iniziale e l'azione che è stata richiesta, e restituisce lo stato finale elaborato secondo le logiche di comportamento.
- (Vista): in pattern di questo tipo la vista non è altro che una funzione che mappa lo stato in una presentazione, cioè fa corrispondere ad ogni possibile stato una sua rappresentazione. Di fatto questo strato può essere o meno integrato nel riduttore, o viceversa, il riduttore può essere integrato nella funzione che si occupa di (ri)elaborare la vista.
Questo pattern a tutti gli effetti è una versione funzionale del pattern MVC. Non essendo orientato agli oggetti non prevede l'uso di classi per rappresentare i diversi strati. Generalmente, l'oggetto è una struttura di dati più o meno manipolabile, l'azione è anch'essa un oggetto e il riduttore è una funzione statica dove di fatto avviene tutta la magia. Se lo chiedete a noi, questo pattern, per quanto vada, oggi, molto di moda, non presenta grosse novità e d'altro canto nemmeno supera il problema di sovraccaricare di responsabilità uno strato rispetto agli altri. Di solito, in progetti grandi sviluppati secondo questo pattern i riduttori sono funzioni di una complessità improponibile. Inoltre, framework che implementano questo pattern (primo tra tutti React), pur presentandosi come snellissimi, hanno il difetto di fallire miserabilmente nel separare la presentazione dalle logiche, e, anzi, probabilmente non si pongono nemmeno tale proposito, dal momento che la vista viene rappresentata direttamente nella funzione del linguaggio JavaScript che elabora le logiche.
Reactive/A eventi
Questa non è una vera e propria architettura, ma un approccio che può essere utilizzato in diversi tipi di design. L'approccio a eventi è molto vicino di per sé al già citato observer pattern, diffuso in framework MVVM. A tutti gli effetti, il tutto si può riassumere in tre strati:
- Sorgenti: sono tutte quelle cose in grado di far succede qualcosa: un pulsante, una chiamata HTTP, un timer, qualsiasi cosa che abbia il potere di innescare un cambiamento di stato. A proposito: un aspetto particolarmente elegante, qui, è che lo stesso stato iniziale si presenta come un evento scatenato all'avvio;
- Pipeline di operatori: sono composizioni di funzioni che combinano gli eventi e ne mappano il contenuto per generare nuovi eventi; di fatto, l'insieme delle stesse potrebbe essere assimilato a un riduttore;
- (Subscriber): sono funzioni che ascoltano e reagiscono agli eventi che emergono dalle pipeline, aggiornando la vista di conseguenza. Nei framework che implementano questo pattern, di solito questo strato si presenta già implementato nel framework ed è più o meno nascosto al programmatore informatico. In Angular (framework a oggetti) è possibile utilizzare solo flussi di eventi e non usare esplicitamente nemmeno un subscriber. Anche in cycle.js (un framework funzionale) i subscriber (qui definiti driver) sono integrati nel framework e non rappresentano una preoccupazione per il programmatore.
Personalmente, troviamo molto elegante la soluzione di trattare lo stato come uno degli eventi in gioco, in modo tale che non ci sia mai un'istruzione esplicita di inizializzarlo né di aggiornarlo. Un altro vantaggio è costituito dalle pipeline, che tendono a non centralizzare troppa logica su una sola funzione. L'altra cosa bella di questo approccio è che mette perfettamente d'accordo programmazione funzionale (utilizzata per gestire i flussi di eventi) e programmazione a oggetti (utilizzata per organizzare i componenti e fornire un'api comoda e scorrevole su tali flussi).
Lo stato, da un punto di vista pratico
Per concludere, vorremmo riflettere un pochino su quanto stato serve nelle applicazioni. Distinguiamo, velocemente, tra lo stato di sessione e lo stato persistente:
- Lo stato di sessione muore quando abbiamo finito (oppure se annulliamo la nostra operazione);
- Lo stato persistente resta a meno che non sia prevista un'azione esplicita per cancellarlo.
Qui, al di là di tutte le trattazioni fatte, ci dovremo porre problemi come dove mettere lo stato, per esempio:
- in memoria: lo stato morirà quando chiudiamo la web app
- nel session storage: è pressoché come metterlo in memoria
- nel local storage (o altro store persistente sul browser): qui sopravvivrà alla chiusura dell'applicazione
- sul server: in questo modo possiamo centralizzare tutto, ma sono richieste più logiche
Personalmente concordiamo sul principio che tutto lo "stato evitabile" andrebbe evitato. D'altra parte, in termini di user experience, ci sono situazioni in cui l'applicazione non può dimenticare tutto.
Forse, piuttosto che non gestirlo, potrebbe essere comodo prendere in considerazione uno strumento (libreria o framework che sia) che ci consenta di farlo con poco poco sforzo. A quel punto, poco importa con quale architettura questo viene fatto; l'importante è capire come lo strumento che adottiamo è pensato, in modo tale da sfruttarlo appieno.