Fie că începem să vorbim despre concepte abstracte, precum algoritmii optimi pentru traversarea unui graf sau sortarea unei liste, fie că explorăm situații concrete, cum ar fi soluții software pentru gestionarea tranzacțiilor bancare sau implementarea unei platforme de e-commerce, care să permită plasarea fără cusur a comenzilor online, certitudinea este că ne confruntăm cu o provocare omniprezentă în lumea noastră, a programatorilor: performanța. În rândul aplicațiilor web și a sistemelor distribuite există nenumărate tehnici de sporire a performanței, precum adăugarea redundanței în rândul serviciilor noastre prin scalare orizontală, paralelizare și procesare distribuită, monitorizare sau trasare a execuției apelurilor primite.
Există, totuși, riscul de a ne concentra prematur în mod excesiv asupra acestor aspecte? Putem oare să atingem o performanță mai bună a serviciilor noastre înainte de a le avea în vedere? Mai avem loc pentru îmbunătățire?
În mod evident, nu există un răspuns definitiv, dar putem să convenim că nu avem o soluție unică care să asigure performanța absolută în toate scenariile. Totuși, în acest articol, îmi propun să explorez acest subiect și să identific potențiale direcții de îmbunătățire, introducând în primă instanță o paradigmă de design diferită pentru aplicațiile web - abordarea reactivă.
Desigur, nu trebuie să mă credeți pe cuvânt când spun că programarea reactivă poate reprezenta o soluție valoroasă în îmbunătățirea performanței serverelor web. Pentru aceasta, am să încerc în cele ce urmează să prezint o argumentație solidă cu privire la afirmația mea. Cum? Mă voi rezuma la ce știu să fac cel mai bine și voi implementa două servere web REST: unul reactiv și unul blocant, bazat pe o abordare tradițională, care urmărește modelul "one thread per request", pe care le voi compara ulterior cu scopul de a arăta diferențe, asemănări, avantaje și carențe ale manierei reactive față de abordarea clasică, punând accentul pe un context cu un număr mare de utilizatori simultani, care efectuează operații intensive din punct de vedere I/O.
Conceptul de reactivitate s-a bucurat de o atenție sporită în ultima vreme, însă pentru a-l defini, este nevoie să avem în vedere principii, concepte sau valori fundamentale de design ale soluțiilor software menite să asigure natura reactivă a unui serviciu.
Elasticitatea reprezintă un concept prin care un sistem este capabil să rămână receptiv în contextul unui volum variat de cereri sau utilizatori concurenți, reușind să crească randamentul în cazul în care mai mulți utilizatori folosesc concomitent serviciul sau, dimpotrivă, să scadă automat atunci când cererea scade. Primul gând când vine vorba de elasticitate ar fi să scalăm sistemul, fie prin scalare orizontală sau verticală. Scalabilitatea va fi însă mereu limitată de existența unor puncte slabe în sistemele dezvoltate, așa zisele "bottlenecks".
Reziliența reprezintă, după cum toți știm, caracteristica sistemului de a-și păstra capacitatea de a răspunde în contextul unor probleme/erori. Evident, conceptul de reziliență este strâns legat de cel de elasticitate, întrucât în absența unei componente care reacționează la variații ale volumului de date, erorile survin. Având în vedere posibila prezență a unor probleme/erori, pentru a păstra natura adaptabilă a sistemului, este necesar ca reziliența să fie un principiu esențial în designul și implementarea unui serviciu web.
Comunicarea bazată pe mesaje reprezintă un mod prin care componentele unui sistem distribuit pot comunica astfel încât elasticitatea și reziliența să fie luate în calcul. Promovând decuplarea, izolarea participanților în comunicare și, totodată, scalabilitatea, comunicarea bazată pe mesaje reprezintă un mod asincron prin care serviciile care comunică reușesc să interschimbe informații fără a fi blocate până la procesarea/trimiterea mesajului de către partea opozantă.
Toate aceste concepte contribuie la definirea reactivității. Ca paradigmă de programare ce vine în ajutorul implementării unui sistem reactiv, putem vorbi de programarea reactivă, care se concentrează la rândul ei pe gestionarea evenimentelor și a schimbărilor de stare într-un mod eficient, bazându-se pe ideea de procesare a acestora într-un mod asincron, fără blocare (non-blocking). Precum orice altă paradigmă de programare, ea constituie o temelie în implementarea soluțiilor software, iar în cazul nostru, a celor caracterizate de reactivitate.
Reprezintă o întrebare cât de validă. Scopul este de a avea sisteme scalabile, decuplate, ușor extensibile și cu o latență cât se poate de mică, iar noi am vrea să credem că știm cum să facem asta, fiindcă am făcut-o de multe ori fără să avem în vedere reactivitatea. Sunt însă aspecte care nu sunt de neglijat, când vine vorba de serviciile reactive, aspecte ce ar putea să justifice alegerea ei în anumite scenarii, printre care:
Izolarea componentelor datorită naturii comunicării dintre acestea, lucru ce duce inevitabil la decuplarea acestora, un indicativ puternic pentru existența unui design bun al sistemului. Ba mai mult, această izolare conferă o notă de extensibilitate soluției, lucru ce confirmă, din nou, existența maturității în arhitectura sistemului.
Mecanismul de rezistență la variațiunile volumului de date (Backpressure). Definit ca un mecanism de protecție, de reacție la creșterea mare a fluxului de date, acesta este esențial într-un sistem reactiv și constituie un enorm ajutor când vine vorba de reziliență. Acesta joacă un rol primordial în ajutarea sistemului de a răspunde cu grație la sarcinile pe care trebuie să le ducă la bun sfârșit, în contextul multitudinii acestora.
Eficiența în managementul resurselor. Programarea reactivă promite o gestionare eficientă a resurselor, inclusiv a memoriei și a puterii de procesare. Acest lucru poate fi datorat și faptului că atunci când vorbim despre un sistem reactiv, avem de a face cu un grup limitat de threaduri, optimizat pentru a facilita procesarea asincronă. Aici, există în multe soluții reactive un procesor capabil să capteze evenimentele ce duc la schimbări de stare, a cărui unic scop este de a gestiona și a distribui munca primită către alte threaduri, ducând la o utilizare a acestora mai eficientă și, în esență, la o nevoie a unui număr mai mic de threaduri. În consecință, problemele de concurență și sincronizare pot fi și ele gestionate mai eficient.
În cele ce urmează, intenționez să rămân în sfera mea de competențe și să vă prezint anumite librării care pot fi utilizate în dezvoltarea unor servicii reactive în Java. Scopul este, bineînțeles, nu să reinventăm roata, ci să înțelegem ce soluții robuste și mature există. Astfel, printre cele mai cunoscute librării sau frameworkuri reactive se numără:
RxJava a fost, pentru multă vreme, librăria standard pentru programarea reactivă în ecosistemul Java, fiind considerată antemergătoarea altor librării. În API-ul acesteia, putem observa cum o soluție bazată pe șabloane de design bine-cunoscute, printre care Observer și Iterator, poate duce la crearea unor fluxuri de date reactive cu care se poate lucra. Avem două mari categorii de obiecte cu care interacționăm: Observable, obiectul care poate primi datele de la o sursă și a cărui stare este de interes pentru Observer, care dorește să fie notificat în urma unei schimbări de stare a acestuia. Astfel, fluxurile de date pot fi tratate ca niște obiecte observabile și pot fi manipulate utilizând operatori asemănători operațiilor din programarea funcțională (map, filter, zip etc.).
Akka poate fi considerată chiar un set de librării menit să ajute în dezvoltarea aplicațiilor distribuite, non-blocante, într-un mediu unde avem de-a face cu concurență sporită. Dezvoltată în Scala urmărind principiile Reactive Manifest, aceasta se bazează pe modelul de actori, care reprezintă entități izolate care comunică între ele prin mesaje.
E momentul oportun să trecem la practică, astfel urmând să vedem cum putem implementa un server reactiv folosind Java 17, Spring WebFlux și Spring Data R2DBC, o componentă ce oferă integrarea driverelor reactive cu baze de date relaționale. În cazul serviciului nostru, vom folosi unul specific pentru PostgreSQL. Vom observa că serverul pe care va rula serviciul nostru va fi Netty, specializat în manipularea apelurilor HTTP asincrone. Înainte să trecem prin pașii necesari implementării unui serviciu HTTP, care nu blochează apelantul până la finele procesării, aș vrea să trec în revistă scenariul pe care îl vom avea în vedere atât în construcția serviciului, cât și în compararea acestuia cu un serviciu REST blocant, clasic, care urmărește un model de tip "one thread per request".
Așadar, vom urmări să implementăm un server pentru o instituție bancară, capabil să înregistreze noi conturi, tranzacții și chiar să caute anumite tranzacții într-un interval dat sau să calculeze soldul dintr-un cont în baza istoricului tranzacțiilor înregistrate. Tot în acest capitol, vom putea urmări cum putem trata și propaga diferite excepții personalizate, astfel încât să evidențiem API-ul reactiv prin care putem manipula fluxurile de date.
Urmează câțiva pași necesari în dezvoltarea serverului reactiv care facilitează efectuarea unor operații de tip CRUD a tranzacțiilor bancare.
Pe lângă dependințele de Spring Webflux și Spring Data R2DBC, am adăugat atât o dependință către Lombok pentru cod șablon, fiindcă recordurile din Java nu se pretează pe entități, cât și pentru Flyway, pentru scripturi de migrare.
dependencies {
implementation 'org.springframework.boot:spring-
boot-starter-data-r2dbc'
implementation 'org.springframework.boot:spring-
boot-starter-webflux'
implementation 'org.flywaydb:flyway-core'
implementation 'org.projectlombok:lombok:1.18.28'
runtimeOnly 'org.postgresql:r2dbc-postgresql'
implementation 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok:1.18.28'
annotationProcessor 'org.
projectlombok:lombok:1.18.28'
testImplementation 'org.springframework.
boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:
reactor-test'
}
În serviciul construit, vom avea două modele: Account și Transaction. Relația dintre acestea este una de tip one-to-many.
Mai jos, putem observa o posibilă implementare a entităților și a relației acestora cu adnotări specifice Spring Data R2DBC.
@Getter
@Setter
@NoArgsConstructor
@Table("account")
public class Account {
@Id
private Long id;
@Column("account_number")
private String accountNumber;
@Column("account_holder")
private String accountHolder;
@Transient
@JsonIgnore
@MappedCollection(idColumn = "account_number")
private List transactions;
}
@Data
@NoArgsConstructor
@Table(name = "transaction")
public class Transaction {
@Id
Long id;
@Column("account_number")
String accountNumber;
@Column("date")
LocalDate transactionDate;
@Column("transaction_details")
String transactionDetails;
@Column("processed_date")
LocalDate processedDate;
@Column("withdrawal_amount")
Double withdrawalAmount;
@Column("deposit_amount")
Double depositAmount;
}
Folosind Spring Data RDBC (Relational Reactive Database Connectivity), putem construi un ReactiveCrudRepository, unde avem posibilitatea de a adăuga metode noi, care vor fi implementate prin reflexie, lucru întâlnit și în alte soluții din suita Spring Data. Principala diferență este dată de natura reactivă a acestuia, astfel tipul de obiecte returnate vor fi Mono sau Flux.
@Repository
public interface TransactionRepository extends ReactiveCrudRepository {
Flux findAllBy(Pageable pageable);
Flux findAllByAccountNumber(
String accountNumber, Pageable pageable);
Mono deleteAllByAccountNumber(
String accountNumber);
Flux
findAllByAccountNumberAndTransactionDateBetween(
String accountNumber, LocalDate after,
LocalDate before, Pageable pageable);
Mono countByAccountNumber(
String accountNumber);
Mono
countByAccountNumberAndTransactionDateBetween(
String accountNumber, LocalDate after,
LocalDate before);
}
În cele ce urmează, vom observa implementarea unei metode de salvare a unei tranzacții în care înlănțuim mai multe operații pe baza de date într-o manieră reactivă, pentru a demonstra puterea API-ului de care dispunem. Putem observa cum accesăm elementul conținut într-un observabil de tip Mono printr-un flatMap, după care salvăm tranzacția. Ba mai mult, returnăm o excepție customizată în cazul în care contul aferent numărului de cont pentru care urmărim să salvăm o tranzacție nu există.
@Transactional
public Mono createTransaction(Transaction transaction) {
return accountRepository
.findByAccountNumber(transaction.getAccountNumber())
.flatMap(account -> transactionRepository
.save(transaction))
.switchIfEmpty(Mono.error(
new AccountNotFoundException()));
}
În final, putem ajunge la implementarea unei metode REST de tip POST aferente metodei din serviciul prezentat anterior.
Observăm multe elemente comune cu o implementare clasică pentru un serviciu rest blocant (precum clasicele adnotări @PostMapping și @RequestBody), însă partea interesantă vine de la modul în care prin acest Fluent API al Reactor Core-ului putem manipula anumite excepții aruncate și putem returna diferite coduri de status HTTP.
@PostMapping
public Mono>
createTransaction(@RequestBody Transaction
transaction) {
return transactionService
.createTransaction(transaction)
.then(Mono.just(ResponseEntity.ok().build()))
.onErrorResume(AccountNotFoundException
.class, error -> Mono.just(
ResponseEntity.notFound().build()));
}
Este, din punctul meu de vedere, cea mai importantă întrebare privind sistemele reactive, pentru că acestea vin la pachet cu costuri suplimentare atât în momentul dezvoltării, cât și în timpul încercărilor de a găsi și fixa posibilele probleme survenite.
Pentru a putea argumenta implementarea unui serviciu reactiv, propun un studiu de caz în care urmăresc să compar două servicii REST, unul reactiv și unul blocant în două ipostaze diferite. Scenariul este același, avem un serviciu capabil să efectueze operații CRUD cu tranzacții bancare, conturi bancare și poate chiar să agrege niște date (spre exemplu, calcularea unui sold din tranzacțiile actuale).
De menționat aici este faptul că am folosit JMeter pentru crearea unor apeluri în masă ce simulează utilizarea serviciului într-o manieră concomitentă de către un număr de utilizatori, JProfiler pentru profilarea serviciilor și pgAdmin pentru a colecta anumite metrici legate de conexiunea cu baza de date.
Acestea fiind spuse, pentru a avea parte de cel mai mare grad de izolare, rezumăm discuția la testarea a două servicii aflate în containere de Docker, fără a lua în considerare aspecte precum scalarea orizontală prin redundanță, load-balancing sau alte îmbunătățiri ce ne-ar putea ajuta să avem un sistem mai "pregătit pentru producție".
Pentru această comparație, contextul testelor este prezența unui număr relativ mic de utilizatori concurenți, unde amândouă serviciile sunt nevoite să calculeze rezultate în memorie. Pentru aceasta, am simulat un calcul de total al soldului corespunzător unui cont adunând sumele din istoricul tranzacțiilor. Graficul aferent acestui test este unul deosebit de interesant, fiindcă observăm cum nici timpul de răspuns, nici randamentul nu diferă atât de mult între cele două servicii. O posibilă interpretare ar fi faptul că în aceste considerente, beneficiile unui sistem reactiv nu sunt prezente, iar serviciul blocant poate face față cu brio.
Figura 1. Număr limitat de utilizatori. Teste efectuate cu JMeter
Numărul de utilizatori concurenți reprezintă o problemă, astfel merită să vedem ce se întâmplă când acesta crește considerabil.
Pentru o bună vizualizare, graficul corespunzător acestor teste conține metrici cu privire la timpul mediu de răspuns la un apel și randament în contextul unui număr mare de apeluri HTTP precum crearea de noi tranzacții, adăugarea unor noi conturi și găsirea tuturor tranzacțiilor pentru un anumit interval de timp.
E de notat faptul că fiecare utilizator concurent a făcut trei operațiuni, deci numărul de apeluri este, în fapt, de trei ori mai mare pentru fiecare dintre cazurile testate. Se observă o creștere în timpul de răspuns asemănătoare celei exponențiale în cazul serviciului blocant, unde am experimentat o limită superioară de apeluri ce pot fi procesate de cca. 9.000-10.000 utilizatori concurenți (30.000 apeluri), moment de la care serviciul meu a început să producă erori precum închiderea conexiunii sau - și mai rău - s-a prăbușit complet.
Aflat în antiteză, serverul reactiv se ridică la nivelul așteptărilor în contextul unui număr de utilizatori mare. Avem un randament bun, un timp de răspuns rezonabil și, în urma testelor, am observat o limită de gestionare a utilizatorilor concurenți aproape dublă, 17.000, răspunzând la 54.000 de apeluri concurente cu o rată de eroare inexistentă.
Figura 2. Număr mare de utilizatori.Teste efectuate cu JMeter
Mai jos, am atașat și o captură din timpul testelor cu JMeter, unde putem observa alte metrici interesante, precum rata de eroare, care a rămas preponderent zero, sau chiar și timpul maxim de răspuns în cazul fiecărui apel în parte.
Figura 3. Test simulând 10.000 utilizatori concurenți ai serviciului reactiv
Dincolo de compararea metricilor de timp de răspuns sau randament cu JMeter, un alt aspect interesant este evidențiat de profilarea celor două servicii și de monitorizarea bazei de date. Utilizând JProfiler pe tot parcursul testelor, am observat, precum mă așteptam, o diferență mare când vine vorba atât de numărul de threaduri create, cât și a numărului de threaduri active în mod concomitent, respectiv blocate în momentul procesării. Figurile de mai jos evidențiază acest lucru, unde este de notat că vârfurile apărute în diferitele metrici coincid cu momentele de testare cu un număr mare de utilizatori.
Astfel, observăm că arhitectura bazată pe event-loop a serviciului reactiv duce la o existență constantă a unui număr mai mic de threaduri - aproximativ 30 - și a unui constant de threaduri active în momentul procesării, spre deosebire de serviciul blocant, care în momentul testelor cu un număr mare de utilizatori, crește numărul de threaduri existente, majoritatea fiind însă într-o stare de așteptare.
Alte diferențe sunt date de utilizarea CPU-ului, serverul reactiv fiind mai eficient și folosindu-l pentru o perioadă mai mică de timp, dar și de memoria utilizată, care, deși comparabilă, în cazul serviciului reactiv rămâne constantă, indicativ al faptului că acesta are puterea de a procesa într-un flux continuu un volum mare de date.
Figura 4. Profilarea serviciului blocking
Figura 5. Profilarea serviciului reactiv
O ultimă metrică interesantă a fost cea legată de numărul de tranzacții pe bază de date, evidențiind diferențele masive între driverul reactiv, respectiv cel blocant folosit în servicii. O captură a metricilor din dbAdmin arată faptul că în urma unui test cu 5000 de utilizatori concurenți, numărul de tranzacții pe bază de date pe minut este de două ori mai mare în cazul reactiv.
Figura 6. Tranzacții pe baza de date
În mod cert, paradigma reactivă generează mult interes în jurul ei, dar implică și o schimbare în gândirea, designul, implementarea sau testarea soluțiilor software. Ea se bazează pe asincronicitate, pentru a asigura izolarea componentelor, iar rezultatul este un sistem extensibil, scalabil, capabil să nu se blocheze în timpul execuției pentru a aștepta terminarea procesării. Deși curba de învățare este ceva mai abruptă, am observat cât este de la îndemână folosirea unor librării deja existente pentru a crea soluții reactive, adaptabile, capabile să facă față cu brio uneia dintre cele mai mari încercări ale soluțiilor software: procesarea unui flux mare de date.
În concluzie, pornind de la subiectul nostru, cred că eficiența și performanța vor continua să fie subiecte esențiale în domeniul programării, iar responsabilitatea de a găsi cele mai bune soluții în funcție de fiecare situație specifică cade pe umerii noștri, cum am văzut și în acest articol. Mereu vor exista multiple abordări pentru diferite provocări, dar, dacă ar fi să ne rezumăm la relația dintre reactivitate și performanță, am putea sumariza că aceasta, deși se arată a fi promițătoare, constituie doar o altă armă în arsenalul nostru, nicidecum o soluție menită să ne rezolve problemele făcând abstracție de context.
Programarea reactivă este interesantă. Implică, ca orice altă paradigmă, un mod de gândire inedit.
În acest articol, am făcut doar o introducere de suprafață în lumea reactivității. Pentru aprofundare, cartea [2] este o sursă bună.
Nu trăim într-o utopie, în aplicațiile reale apar constrângeri care duc deseori la imposibilitatea implementării unei arhitecturi pe deplin reactivă.
În lumea reactivă, apar multe probleme interesante ce ar merita, la rândul lor, un articol: sincronizarea sau integrarea datelor atunci când e nevoie de o anumită ordonare, probleme de reprocesare a datelor (în cazul comunicării pe mesaje) și chiar probleme de debug. Topicul de depanare a unor probleme este și el deosebit de interesant, iar mecanismul de monitorizare și logare este absolut necesar în cazul sistemelor asincrone care procesează în mod paralel.
Pentru a înțelege arhitectura reactivă a Spring Webflux, o excelentă resursă este [4]. Aici putem vedea vizual cum funcționează mecanismul din spate și de ce nu e nevoie de o armată de threaduri pentru procesare.
J. Bonér, D. Farley, R. Kuhn, and M. Thompson, "The reactive manifesto",
O. Dokuka and I. Lozynskyi, Hands-on reactive programming in Spring 5: build cloud-ready, reactive systems with Spring 5 and Project Reactor. Packt Publishing Ltd, 2018.