Tech Blog

Reactive VS Imperative - Primo Episodio

Uno scontro amichevole tra paradigmi

Mauro Celani
Software Developer
Cristian Bianco
Software Developer
11 minuti di lettura
reactive programming, imperative programming e software design

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

Figura 1 - Relazione dei principi del Reactive Manifesto
Figura 1 - Relazione dei principi del 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:

  1. Responsivi: risposte tempestive focalizzandosi nel minimizzare i tempi di risposta, in quanto le richieste devono essere evase nel minor tempo possibile.
  2. 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.
  3. Elastici: il sistema si adatta alla frequenza degli input, incrementando o decrementando al bisogno le risorse.
  4. 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.

Figura 2 - Esempio di programmazione reattiva
Figura 2 - Esempio di programmazione reattiva

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:

Figura 3 - Due stream che si fondono insieme
Figura 3 - Due stream che si fondono insieme

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.

Figura 4 - Esempio di processo imperativo
Figura 4 - Esempio di processo imperativo

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.

Figura 5 - Un flusso di elaborazione tradizionale
Figura 5 - Un flusso di elaborazione tradizionale

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.

Figura 6 - Un flusso di elaborazione reattivo
Figura 6 - Un flusso di elaborazione reattivo

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:

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.

Figura 7 - Loghi dei prodotti Spring utilizzati
Figura 7 - Loghi dei prodotti Spring utilizzati

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.

Figura 8 - Insiemi di tecnologie imperative e reattive9
Figura 8 - Insiemi di tecnologie imperative e reattive9

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
Figura 9 - Dati meteorologici sparsi per il globo
Figura 9 - Dati meteorologici sparsi per il globo

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.

Figura 10 - Esempio di due microservizi

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:

Figura 11 - Aggregatore
Figura 11 - Aggregatore

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.

Figura 12 - Service Discovery
Figura 12 - Service Discovery

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.

Figura 13 - Api Gateway che comunica con Service Discovery
Figura 13 - Api Gateway che comunica con Service Discovery

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.

Figura 14 - Architettura completa
Figura 14 - Architettura completa

Possiamo elencare una sequenza di passi che vengono eseguiti per ottenre l'informazione desiderata:

  1. l'utente carica una pagina restituita dal frontend
  2. all'interno della pagina esiste una chiamata al servizio aggregato datas-by-city
  3. scatta una chiamata asincrona in direzione dell'Api Gateway
  4. l'Api Gateway conosce la destinazione del servizio aggregato
  5. l'aggregatore chiama i suoi nodi sottostanti
  6. I nodi vanno a leggere le informazioni dalla base dati
  7. l'aggregatore mette insieme le informazioni ricevute dai due nodi all'interno di un modello e lo resituisce
  8. 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à.

Figura 15 - Cloud
Figura 15 - Cloud

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.

@GetMapping("/city-by-name/{cityName}")
public Collection<City> getCityByName(@PathVariable String cityName)
  throws Exception {

  return _cityRepository.findByName(cityName);
}
Figura 16 - Imperative City

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.

public RouterFunction<ServerResponse> cityRoutes() {
  return route(GET("/city-by-name/{name}"), this::findByName);
}
Figura 17a - Reactive City

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.

private Mono<ServerResponse> findByName(ServerRequest request) {

  String name = request.pathVariable("name");

  Flux<City> cities = _cityRepository.findByName(name);

  if (_isSSERequest(request)) {
    return _toSSEResponse(
      cities,
      new ParameterizedTypeReference<ServerSentEvent<City>>() {},
      c -> String.valueOf(c.getId()));
  }

  return ok().body(cities, City.class);
}
Figura 17b - Reactive City

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.

@GetMapping("/datas-by-city-id/{cityId}")
public List<Data> getDatasByCityId(@PathVariable long cityId)
  throws Exception {

  return _dataRepository.findByCityId(cityId);
}
Figura 18 - Imperative Weather

Reactive

Di seguito definiamo la coppia rotta - listener.

public RouterFunction<ServerResponse> dataRoutes() {
  return route(GET("/datas-by-city/{cityId}", this::findDatasByCityId));
}
Figura 19a - Reactive Weather

Anche in questo caso andiamo ad utilizzare la parte reattiva fornita da MongoDB e wrappata attraverso i repository di Spring.

private Mono<ServerResponse> findDatasByCityId(ServerRequest req) {

  Flux<Data> datas = _dataRepository
    .findByCityId(
      Long.parseLong(
        req.pathVariable("cityId")));

  return _isSSERequest(req)
    ? _toSSEResponse(
      datas,
      new ParameterizedTypeReference<ServerSentEvent<Data>>() {},
      data -> String.valueOf(data.getDataId()))
    : ok().body(datas, Data.class);
}
Figura 19b - Reactive Weather

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.

@Override
@GetMapping("/city-name-weather")
public Collection<CityContainerModel> getCityWeather(
    @RequestParam String cityName)
  throws Exception {

  Collection<City> cities = _cityProxy.getCityByName(cityName);

  if (cities.isEmpty()) {
    throw new NoSuchCityException(
      "The city with name '" + cityName + "' wasn't found.");
  }

  return cities.stream()
    .map(city -> CityContainerModel.of(
      city, _weatherProxy.getDatasByCityId(city.getId())))
    .collect(Collectors.toList());
}
Figura 20 - Imperative Aggregator

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.

@GetMapping("/city-name-weather")
public Flux<CityContainerModel> getCityWeather(
  @RequestParam String cityName) {

  Flux<CityContainerModel.City> cityByName =
    _reactiveCityProxy.getCityByName(cityName);

  return cityByName.concatMap(
    city -> _reactiveDataProxy
      .getDatasByCityId(city.getId())
      .collectList()
      .map(datas -> CityContainerModel.of(city, datas))
  );

}
Figura 21 - Reactive Aggregator

Riferimenti

Potete trovare i sorgenti completi al seguente link:

https://github.com/smclab/reactive-vs-imperative

Benchmark

Tools

Benchmark Tools
Benchmark 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.

Figura 22 - Scenario di test
Figura 22 - Scenario di test

Imperativo

Nel seguente grafico vediamo il comportamento del numero di risposte al secondo rispetto al tempo per lo scenario imperativo.

Figura 23 - Scenario imperativo
Figura 23 - Scenario imperativo

Reattivo

Analogamente vediamo il comportamento per lo scenario reattivo, non ci sono particolari evidenze.

Figura 24 - Scenario reattivo
Figura 24 - Scenario reattivo

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.

Figura 25 - Percentili a confronto
Figura 25 - Percentili a confronto

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.

Figura 26 - Threads a confronto
Figura 26 - Threads a confronto

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.

Figura 27 - Uso della CPU a confronto
Figura 27 - Uso della CPU a confronto

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.

Figura 28 - Uso della memoria a confronto
Figura 28 - Uso della memoria a confronto

Credits

Icons made by:

from www.flaticon.com

scritto da
Mauro Celani
Software Developer
Affascinato dall'informatica fin dall'infanzia, già nei primi anni delle superiori Mauro inizia il suo percorso cimentandosi con i primi linguaggi di programmazione. Conseguita la laurea in scienze informatiche, comincia la sua carriera nel mondo dello sviluppo back-end accumulando esperienza soprattutto su Java, ma allo stesso tempo coltivando conoscenze e curiosità nei confronti del front-end e della parte sistemistica. Una volta conosciuto Liferay ne rimane colpito e decide di specializzarsi, proprio durante questa fase di approfondimento viene in contatto con SMC dove è ad oggi un Senior Developer.
Cristian Bianco
Software Developer
Appassionato di linguaggi e di programmazione funzionale e reattiva. In SMC ha il ruolo di Senior Developer, specializzato nello sviluppo di soluzioni a microservizi, OSGi e in progetti Liferay. Segue e contribuisce alla comunità Java, sopratutto nella divulgazione dei paradigmi funzionali introdotti nelle versioni più recenti.

Potrebbero interessarti anche…