Qualcuno potrebbe giustamente obiettare che ogni volta che abbiamo a che fare con HTTP, essendo un protocollo disconnesso, abbiamo uno scenario che ha problematiche simili a quello asincrono che abbiamo descritto. Aggiungo io che ogni volta che abbiamo a che fare con uno sistema che non usa lock pessimistici in lettura abbiamo a che fare con uno scenario simile.
In realtà le problematiche sono due, molto diverse tra loro, ma entrambe da comprendere e maneggiare con cura e consapevolezza.
Concorrenza ottimistica
Lo scenario a prescindere da come viene generato il modello in lettura è:
- richiesta HTTP in GET
- i dati vengono letti dal db
- la connessione viene chiusa
- un oggetto serializzato viene ritornato al client
- …
In tutti i casi al punto [3] i dati che abbiamo in mano sono vecchi, e per definizione sbagliati. Ergo per essere certi che l’utente non modifichi roba che già qualcun’altro ha modificato dobbiamo introdurre il concetto di versione dei dati, ad esempio un banale campo RowVersion nella tabella:
- richiesta HTTP in GET
- i dati vengono letti dal DB
- insieme alla loro versione
- la connessione viene chiusa
- un oggetto serializzato viene ritornato al client
- Il client a questo punto modifica i dati
- in POST rimanda
- i dati modificati
- la versione originale
- Il server in fase di scrittura verifica che la versione sia uguale a quella che sta cercando di modificare
Funziona, non è per nulla complesso e mi aspetto che sia già così. Introduce ovviamente un interessante problema di UX, perché l’utente che ha davanti una bella form con tanti dati ne modifica un po’, salva e quando il salvataggio fallisce e dobbiamo trovare un modo furbo e semplice per far capire all’utente:
- perché il salvataggio è fallito
- quali sono i dati in conflitto
- permettergli di vedere la nuova versione dei dati in conflitto
- non perdere le sue modifiche pendenti
Che, in soldoni, equivale a consentire all’utente di risolvere un conflitto durante una merge. Tutto tranne che semplice sia dal punto UX che tecnico.
Conversazione asincrona
Qui le cose si fanno un filino più complesse. Se non siete in uno scenario stile fire & forget, ma avete la necessità di saper quando il processo di modifica dei dati è completo, dato che la verità per l’utente è il modello in lettura avete bisogno di sapere quando il modello asincrono è stato aggiornato. Il giro normale è:
- POST per scrivere i dati
- scrittura dell’aggregato
- “notifica” asincrona verso il denormalizzatore
- denormalizzazione del modello/i in lettura
A questo punto l’inghippo è che al punto [3] la connessione HTTP ha ritornato al client un bel 202, quindi il client non sa più nulla. Avete due strade:
- ammazzare in polling il server per capire quando il modello in lettura ha la versione che vi aspettate abbia: il che però comporta anche che il client conosca la strategia di generazione delle versioni
- aspettare una notifica dal server: ma come fa il server a sapere che deve notificare un determinato client? Non lo sa.
Abbiamo bisogno quindi di tener traccia di quella che potremmo definire una sorta di conversazione asincrona:
- POST per scrivere i dati
- il client appende al post, ad esempio in un header, un GUID che rappresenta l’ID della conversazione
- scrittura dell’aggregato
- notifica asincrona verso il denormalizzatore (parliamo dopo del trasporto)
- portando con se l’ID della conversazione
- denormalizzazione del modello/i in lettura
- portando con se l’ID della conversazione
- notifica ai client che la conversazione con ID tal dei tali è completata, uno dei client sarà contento gli altri se ne fregheranno
- il tutto può essere fatto con svariate strategie con SignalR, ad esempio.
La chiave di tutto sta qui:
il client appende al post, ad esempio in un header, un GUID che rappresenta l’ID della conversazione
Deve essere il client a gestire l’ID della conversazione, perché non vogliamo la spiacevole situazione:
- POST per scrivere i dati
- Il server risponde con 202 e l’ID della conversazione
Il client non riceve mai la risposta HTTP e di conseguenza non conosce l’ID della conversazione e perde la possibilità di sapere cosa e quando succederà.
Quindi?
Mentre la gestione della concorrenza ottimistica è essenziale in qualsiasi scenario, la conversazione è necessaria solo ed esclusivamente se il modello in lettura viene generato in maniera asincrona.
Non usate CQRS con il modello in lettura asincrono
Il dottore non ci ha prescritto che il modello in lettura debba essere asincrono, tutti i database (documentali e non) supportano in qualche modo il concetto di proiezione (ad esempio le viste in un DB relazionale). Le proiezioni sono un perfetto modo per implementare il modello in lettura.
Se non avete milioni di utenti concorrenti e contemporanei e le viste sono lente, la vostra soluzione non è un modello in lettura asincrono, ma probabilmente il design del modello ha un po’ di problemi. Inoltre se vi ritrovata ad avere la necessità di costruire un modello in lettura che sia la summa di più aggregati che magari provengono da bounded context diversi, la soluzione non è un modello in lettura asincrono ne men che meno una proiezione generata con join che fanno i salti mortali carpiati. Forse è il caso che:
Per capirci:
- scrivo attraverso una stored procedure
- leggo da una vista
è una validissima implementazione di CQRS, semplice probabilmente e ridotta all’ossa ma valida.
Non scala? E devo scalare
Molto bene. Se siete certi che è tutto giusto e avete bisogno di più potenza di fuoco allora vada per la versione asincrona, consci che:
- la complessità costa
- la UX deve digerire la nuova complessità
- la UX di conseguenza costa
Quindi prima di dire che dovete per forza scalare io tornerei ad analizzare quello che abbiamo alla ricerca di colossali problemi di performance, e nuovamente alla ricerca degli errori nella definizione dei boundary che spesso sono la sorgente di tanti mali.
CQRS è proprio in casi come questo che dimostra tutte le sue potenzialità. Avendo modelli diversi per canali con compiti diversi semplifica di molto l’evoluzione, anche infrastrutturale e/o tecnologica.