In questo primo episodio dell'SMC Aperitech andremo ad approfondire le peculiarità tra il nuovo paradigma di programmazione reattiva e quello imperativo più tradizionale.
La maggior parte di voi conoscerà bene l'approccio tradizionale, quindi facciamo un'approfondimento iniziale riguardante un po' di teoria sulla programmazione reattiva.
Reactive Manifesto
Il cuore dei principi della programmazione reattiva sono raccolti all'interno del Reactive Manifesto. Questo documento è stato redatto da un gruppo di sviluppatori esperti, rappresentati da Jonas Boner, riunitosi nel 2013 per definire i punti principali di un'architettura reattiva. Ad oggi il manifesto è stato sottoscritto da quasi 30.000 persone.
Esso è composto da quattro punti cardine e le architetture reattive devono quindi attenersi alle seguenti direttive:
- Responsivi: risposte tempestive focalizzandosi nel minimizzare i tempi di risposta, in quanto le richieste devono essere evase nel minor tempo possibile.
- Resilienti: il sistema deve rispondere anche in caso di guasti. Il guasto sulle singole componenti dell'architettura non deve compromettere l'intero sistema, di conseguenza deve essere sempre garantita una risposta ed eventualmente un messaggio di default. In questi casi si può fare affidamento anche a librerie come Netflix Hystrix o Resilience4J.
- Elastici: il sistema si adatta alla frequenza degli input, incrementando o decrementando al bisogno le risorse.
- Orientati ai messaggi: la comunicazione tra gli elementi dell'architettura è basata sullo scambio asincrono di messaggi. Tali messaggi sono scambiati sfruttando il meccanismo delle code e quindi attraverso l'uso di message broker: due tipici esempi sono Apache Kafka o RabbitMQ.
Programmazione reattiva
La programmazione reattiva è un paradigma di programmazione costruito s ulla nozione del cambiamento dei valori nel tempo e la propagazione dei cambiamenti. Il migliore esempio per spiegare questo concetto è il foglio di calcolo elettronico, dove data una funzione di somma di due input al loro cambiamento verrà calcolato immediatamente il risultato.
Nell'esempio abbiamo un foglio di calcolo dove dati 2 input e una funzione di somma sarà possibile notare come al cambiamento di uno dei due valori in input verrà calcolata immediatamente la somma in modo reattivo.
Flussi o Stream
Nella programmazione reattiva, la gestione dei flussi di dati asincroni viene naturale (reactive stream), ogni cosa può essere un flusso, come ad esempio i dati provenienti da una base dati, variabili, properties, strutture dati, input degli utenti e anche code.
Su questi flussi è possibile applicare delle funzioni che li vanno a manipolare:
- Creati
- Filtrati
- Trasformati
- Combinati fra loro (denominata zip)
Tornando all'esempio del foglio di calcolo, possiamo immaginare i due campi di input un po' come dei flussi che vengono combinati fra loro con l'operazione di somma, che "zippa" i due stream proprio come la zip di una cerniera lampo:
Solo recentemente anche Java ha introdotto questi concetti, con la
versione 9 JEP 266 in
particolare, abbiamo a disposizione l'interfaccia java.util.concurrent.Flow
e in seguito alla standardizzazione di org.reactivestream
sono state inserite
le API basate su quattro componenti:
- Publisher
- Subscriber
- Subscription
- Processor
Vantaggi
I vantaggi della programmazione reattiva:
- Cerca di ottimizzare l’utilizzo di risorse riducendo per esempio il numero di thread generati e utilizzati contemporaneamente
- Gestione della back-pressure in maniera semplice
- Facile da leggere per chi lo utilizza e per programmatori che arrivano dalla Functional Programming sono presenti molti pattern da poter riusare
- Rende molto più semplice il lavoro in parallelo e la concorrenza, come abbiamo visto in precedenza è una caratterustica più naturale
- È offerta la stessa API (Publisher, Subscriber, Subscription e Processor) per l’accesso al database, accesso in rete, coda di messaggi e molto altro
Svantaggi
Chiaramente esistono anche degli svantaggi:
- Curva di apprendimento ripida, è richiesto parecchio tempo per familiarizzare con le notazioni della programmazione funzionale
- Difficile da leggere per chi ha un background imperativo. Può risultare ostico per colore che sono abituati a scrivere codice tradizionale
- Non è semplice eseguire operazioni di debug per capire quale parte di codice non sta funzionando a dovere
- Gestione e monitoraggio costante dei vari elementi dell'architettura
Applicabilità
La programmazione reattiva è consigliata quando tutte le fonti di dati sono non bloccanti e eseguibili in parallelo. Alcuni esempi possono essere socket (java.nio), driver database sql (r2dbc) e nosql (reactive mongodb cassandra...), richieste HTTP con SSE e WebSocket (webflux, vertx web..), message broker (kafka stream, reactor-rabbitmq...) e molte altre.
Nel caso contrario è possibile comunque "wrappare" una API bloccante all’interno di un flusso reattivo circoscrivendo i threads bloccati in un thread-pool così da evitare che l’applicazione si blocchi per colpa di quest’ultima.
È sconsigliato utilizzare la programmazione reattiva quando si ha a che fare con la maggior parte delle fonti di dati bloccanti, e wrapparle tutte non è una buona idea, sarebbe controproducente.
Inoltre bisogna escludere applicazioni di tipo “imperativo”, ovvero una sequenza ben definita di procedure, in cui l'elemento attualmente attivo ha bisogno dell'output dell'elemento precedente per poter iniziare il suo lavoro.
Multi threading
Facciamo un ripasso delle architetture su cui tutti siamo abituati a lavorare giorno per giorno. I nostri application server, come ad esempio JBoss, gestiscono una coda di richieste in ingresso e un pool di thread in grado di elaborare queste richieste.
All'arrivo di una nuova richiesta nella coda viene impiegato un nuovo thread oppure uno presente nel thread pool. Questo thread ha il compito di accompagnare il task per tutto il suo ciclo di vita, infatti, nel caso in cui il task in esecuzione esegue una chiamata bloccante (ad esempio verso un database o servizio esterno), anche lo stesso thread si mette in attesa. Solo quando la chiamata bloccante sarà terminata il thread continuerà ad eseguire il task. Il thread verrà rilasciato solo al termine di tutto il task. Quello che si evidenzia è che abbiamo un rapporto 1 a 1 fra task e thread.
Event Loop
Discorso diverso vale per quello che si definisce loop degli eventi, uno dei pattern più largamente utilizzati in ambito programmazione reattiva.
L'event loop ha l'onere di gestire le richieste in input e tratta ognuna di queste come se fosse un evento relazionato ad un listener. Inoltre ha il compito di decidere a chi inoltrare le chiamate che viaggiano in direzione di sistemi esterni. In questo caso verrà verificato se esiste già un listener che rappresenta tale servizio da poter riusare, altrimenti ne verrà creato uno nuovo.
L'event loop decide la schedulazione dei thread in base alle necessità del servizio reattivo esterno o alla libreria asincrona e svolgerà il suo lavoro senza bloccare il ciclo. Nel caso dell'arrivo di una nuova richiesta, questa entrerà nell'event loop che potrà decidere se sfruttare lo stesso listener creato da una richiesta precedente.
Una volta che il sistema esterno termina l'elaborazione e ritornerà una risposta, questa verrà nuovamente gestita dall'event loop che saprà a chi far tornare l'informazione.
Il vantaggio è che non ci sono thread che restano bloccati in attesa della risposta ma sarà bensì una callback che verrà richiamata non appena la risposta sarà a disposizione del chiamante, andando a risvegliare quella parte di codice che aveva iniziato la chiamata asincrona.
I framework che implementano la programmazione reattiva e utilizzano l'event loop sono:
- Specifici per il linguaggio Java:
- Poliglotti:
Spring
Per il nostro caso abbiamo deciso di adottare Spring boot e quindi Project Reactor per sfruttare le componenti reattive di WebFlux. Reactor può essere utilizzato come una libreria esterna anche su progetti non Spring.
Nella demo, che andremo ad approfondire nei paragrafi successivi, sono state utilizzate le due implementazioni di Spring imperativa e reattiva, rispettivamente Spring MVC e Spring WebFlux.
In Spring MVC tutto il codice è scritto con l'approccio imperativo e le chiamate verso i sistemi esterni sono di tipo bloccante. Contrariamente a quanto accade su Spring WebFlux tutta la logica è scritta in maniera reattiva, garantendo un flusso end-to-end non bloccante. In particolare per la parte HTTP è stato sfruttato Netty, per i client Reactor HTTP Client e per i connettori abbiamo R2DBC una versione non bloccante contrapposta a JDBC che invece risulta bloccante.
La nostra Demo
Per realizzare la nostra demo abbiamo raccolto i dati meteorologici di alcune citta sparse intorno al globo. Nello specifico abbiamo due entità:
- City che contiene i dati 22.635 città
- Weather che, relazionata alla prima tramite cityId, contiene tutti i dati meteorologici sparsi in 814.860 record
Per arricchire le entità presenti all'interno della nostra architettura abbiamo deciso di utilizzare due tipologie diverse di database: SQL e NoSQL. Vista la differenza nel numero di dati contenuti all'interno delle due tabelle si è optato per salvare le città su PostgreSQL e i dati meteorologici su MongoDB.
Microservizi
Volevamo mettere in piedi un esempio di quello che può essere un esempio di microservizi autoconsistenti, quindi abbiamo messo davanti ai database uno spring boot per ognuno. Esponendo i relativi servizi per ottenere la città a partire dal nome e i dati meteorologici a paritre dal cityId.
Aggregatore
Successivamente si è dimostrato necessario ottenere un dato aggregato delle due informazioni, dato il nome della città ottenere le sue informazioni meteorologiche. Quindi abbiamo inserito un terzo attore in grado di richiamare le due entità precedenti e elaborare un dato aggregato:
Nel capitolo del Reactive Manifesto abbiamo parlato di elasticità, caratteristica per cui si richiede che il sistema riesca a gestire eventuali casi di incremento di richieste verso una singola entità dell'architettura. In questa ottica, potremmo avere più istanze della medesima entità che devono comunque continuare dialogare fra loro, quindi abbiamo bisogno di qualcuno che ci dica dove si trova il servizio che abbiamo intenzione di richiamare.
Service Discovery
Questa lacuna viene colmata dal Service Discovery che è sostanzialmente un registro dei nodi attivi. Durante la fase di startup di ogni singolo nodo avviene la sottoscrizione verso il Service Discovery al quale sarà notificato la presenza della nuova istanza. Per la nostra demo abbiamo deciso di adottare la versione realizzata da Netflix denominata Eureka.
In altri scenari si possono utilizzare le soluzioni integrate all'interno degli orchestratori di container come ad esempio Docker Swarm o Kubernates.
Api Gateway
Manca ancora un elemento per rendere fruibile la nostra architettura: l'Api Gateway. Questa rappresenta l'entità da richiamare dall'esterno come singolo punto di accesso a tutti i servizi presenti.
Questo componente è in stretto contatto con il service discovery, attraverso il quale conosce i servizi attualemente attivi e le loro api esposte.
Frontend
Tutti i componenti appena realizzati rappresentano la nostra architettura di backend, ma per rendere fruibili i dati per l'utente finale abbiamo bisogno di un frontend.
Qui possiamo utilizzare le tecnologie che più preferiamo, in particolare noi abbiamo scelto NodeJS.
Possiamo elencare una sequenza di passi che vengono eseguiti per ottenre l'informazione desiderata:
- l'utente carica una pagina restituita dal frontend
- all'interno della pagina esiste una chiamata al servizio aggregato datas-by-city
- scatta una chiamata asincrona in direzione dell'Api Gateway
- l'Api Gateway conosce la destinazione del servizio aggregato
- l'aggregatore chiama i suoi nodi sottostanti
- I nodi vanno a leggere le informazioni dalla base dati
- l'aggregatore mette insieme le informazioni ricevute dai due nodi all'interno di un modello e lo resituisce
- infine l'informazione viene modellata per essere visualizzata sulla pagina dell'utente
Container
Chiaramente una architettura di questo tipo regala il meglio di se quando ogni singola entità viene ospitata all'interno di un container. Nella nostra demo abbiamo utilizzato Docker, realizzando per l'appunto i Dockerfile e i docker-compose.yml che è uno dei tanti modi per realizzare un container.
In questo modo abbiamo realizzato degli stampini delle nostre entità e possiamo avviarle facilmente trovando il nostro sistema pronto all'uso.
Distribuzione
Avvalendosi dei container non abbiamo solamente il vantaggio della semplicità di avvio, ma soprattutto quello di poter distribuire la nostra architettura all'interno di un cloud. Inoltre, settando i dovuti meccanismi di auto-scaling, possiamo vedere le nostre entità replicarsi in caso di necessità.
Sorgenti
Di seguito possiamo vedere alcuni esempi che mettono a confronto il codice scritto in maniera tradizionale e la sua implementazione utilizzando il paradigma reattivo.
City
Imperative
Per realizzare l'API abbiamo utilizzato le annotazioni di Spring che ci permettono di creare in maniera agevole il servizio. Quest'ultimo prende in input il nome della città e passa il parametro alla chiamata al repository, che restituirà un insieme di città che possiedono il nome dato in input.
Reactive
In maniera reattiva abbiamo usate le RouterFunction di Spring WebFlux che permettono di mappare una rotta con un metodo. Riprendendo quanto detto nel paragrafo dell'Event Loop, la rotta rappresenta l'evento e il metodo è il listener.
Sotto possiamo vedere l'implementazione del metodo che, come accadeva per la versione imperativa, chiama lo strato di persistenza. Differentemente però i driver non saranno di tipo bloccante JDBC ma useremo la versione reattiva R2DBC implementata su Reactor. Grazie a questa differenza il valore di ritorno non contiene un semplice insieme ma bensì un flusso di elementi.
In questo modo abbiamo la possibilità di tornare al client le informazioni sottoforma di stream una città alla volta, esattamente quello che accade quando riproduciamo un film in streaming, oppure come una informazione sincrona restituendo il blocco di città per intero sottoforma di JSONArray.
Weather
Imperative
Analogamente a quanto visto per la città, i dati meteorologici vengono ritornati come una semplice lista di dati. Grazie ai driver del repository, per noi sarà trasparente la tipologia di database che è stata installata al di sotto.
Reactive
Di seguito definiamo la coppia rotta - listener.
Anche in questo caso andiamo ad utilizzare la parte reattiva fornita da MongoDB e wrappata attraverso i repository di Spring.
Aggregator
Imperative
L'operazione di aggregazione per la parte imperativa si riassume nel recuperare l'insieme di tutte le città e quindi per ogni città andiamo a procurarci i relativi dati meteo.
N.B.: In questo caso ho voluto usare uno stream per evidenziare che questo tipo di strutture possono essere usate anche per scrivere codice imperativo, infatti può essere facilmente riconvertito in un ciclo for/while.
Reactive
In questo caso è stato utilizzata l'annotation di Spring al posto della rotta per dimostrare che è possibile usare anche questo approccio. Nel momento in cui il client HTTP Reattivo andrà ad aprire un flusso noi riusciamo ad inserirci trasformandolo con ad esempio il metodo concatMap (l'equivalente di un flatMap per la programmazione funzionale). Per ogni città, andiamo ad associare i relativi dati meteo che, in questo specifico caso, saranno restituiti come unico blocco. Infine, sarà restituito un flusso di modelli del tipo CityContainerModel dove al suo interno avremo la città e i suoi relativi dati.
Riferimenti
Potete trovare i sorgenti completi al seguente link:
https://github.com/smclab/reactive-vs-imperativeBenchmark
Tools
I tool che abbiamo utilizzato per eseguire il benchmark sono:
- Grafana permette la creazione di grafici a partire da più sorgenti
- Prometheus è una sorta di agent che espone sotto un singolo servizio tutte le metriche
- Apache JMeter è stato usato per generare lo scenario e far partire le chiamate per simulare una situazione di carico
Scenario
Lo scenario dura in tutto due minuti e quaranta secondi, prevede una rampa di ingresso di circa 30 secondi, alla fine del quale verrà raggiungiunto il picco di 100 utenti contemporanei, infine la rampa di uscita vedrà scemare il numero degli utenti in 10 secondi.
Imperativo
Nel seguente grafico vediamo il comportamento del numero di risposte al secondo rispetto al tempo per lo scenario imperativo.
Reattivo
Analogamente vediamo il comportamento per lo scenario reattivo, non ci sono particolari evidenze.
Percentili
Nel grafico dei percentili, dove i tempi di risposta vengono rappresentati in ordine crescente vediamo come la curva imperativa (in rosa) e quella reattiva (in grigetto) al 90° percentile si discosta di ben 110 ms. Ne consegue che le risposte, elaborate dallo scenario che ha visto protagonisti i nodi sviluppati con codice reattivo, sono state evase in tempi significativamente inferiori.
Threads
Quello che è ancora più evidente è il numero dei thread impiegati in uno scenario rispetto all'altro. Infatti nello scenario imperativo sono stati allocati circa 250 thread per portare a compimento le operazioni di aggregazione contro i soli 22 dello scenario reattivo. Questo risultato si raggiunge grazie all'uso di chiamate non bloccanti ottimizzando al massimo le risorse.
CPU
Sui seguenti grafici viene riportato il comportamento della CPU, a sinistra lo scenario imperativo e a destra quello reattivo. Possiamo notare che la componente più sollecitata dell'architettura è l'aggregatore (sul grafico la linea verde), il quale nello scenario imperativo ha quasi utilizzato tutte le sue risorse, contrariamente in quello reattivo vediamo la curva adagiarsi addirittura in mezzo alle altre due.
Memoria
Per quanto riguarda la gestione della memoria, ci accorgiamo che nello scenario imperativo (a destra) viene fatto poco uso di nuovi oggetti che infatti vengono promossi nelle fasi successive. Mentre sullo scenario reattivo abbiamo un uso più largo di oggetti nuovi che quindi sono allocati e subito liberati.
Credits
Icons made by:
from www.flaticon.com