ABONAMENTE VIDEO REDACȚIA
RO
EN
NOU
Numărul 150
Numărul 149 Numărul 148 Numărul 147 Numărul 146 Numărul 145 Numărul 144 Numărul 143 Numărul 142 Numărul 141 Numărul 140 Numărul 139 Numărul 138 Numărul 137 Numărul 136 Numărul 135 Numărul 134 Numărul 133 Numărul 132 Numărul 131 Numărul 130 Numărul 129 Numărul 128 Numărul 127 Numărul 126 Numărul 125 Numărul 124 Numărul 123 Numărul 122 Numărul 121 Numărul 120 Numărul 119 Numărul 118 Numărul 117 Numărul 116 Numărul 115 Numărul 114 Numărul 113 Numărul 112 Numărul 111 Numărul 110 Numărul 109 Numărul 108 Numărul 107 Numărul 106 Numărul 105 Numărul 104 Numărul 103 Numărul 102 Numărul 101 Numărul 100 Numărul 99 Numărul 98 Numărul 97 Numărul 96 Numărul 95 Numărul 94 Numărul 93 Numărul 92 Numărul 91 Numărul 90 Numărul 89 Numărul 88 Numărul 87 Numărul 86 Numărul 85 Numărul 84 Numărul 83 Numărul 82 Numărul 81 Numărul 80 Numărul 79 Numărul 78 Numărul 77 Numărul 76 Numărul 75 Numărul 74 Numărul 73 Numărul 72 Numărul 71 Numărul 70 Numărul 69 Numărul 68 Numărul 67 Numărul 66 Numărul 65 Numărul 64 Numărul 63 Numărul 62 Numărul 61 Numărul 60 Numărul 59 Numărul 58 Numărul 57 Numărul 56 Numărul 55 Numărul 54 Numărul 53 Numărul 52 Numărul 51 Numărul 50 Numărul 49 Numărul 48 Numărul 47 Numărul 46 Numărul 45 Numărul 44 Numărul 43 Numărul 42 Numărul 41 Numărul 40 Numărul 39 Numărul 38 Numărul 37 Numărul 36 Numărul 35 Numărul 34 Numărul 33 Numărul 32 Numărul 31 Numărul 30 Numărul 29 Numărul 28 Numărul 27 Numărul 26 Numărul 25 Numărul 24 Numărul 23 Numărul 22 Numărul 21 Numărul 20 Numărul 19 Numărul 18 Numărul 17 Numărul 16 Numărul 15 Numărul 14 Numărul 13 Numărul 12 Numărul 11 Numărul 10 Numărul 9 Numărul 8 Numărul 7 Numărul 6 Numărul 5 Numărul 4 Numărul 3 Numărul 2 Numărul 1
×
▼ LISTĂ EDIȚII ▼
Numărul 137
Abonament PDF

Este reactivitatea cheia spre succes?

Darius Haș
Java Software Engineer @ Accesa



PROGRAMARE


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.

Reactivitatea. Cuvânt la modă sau o nouă paradigmă cu mult potențial?

Principiile reactivității

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.

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.

De ce am alege să construim un sistem reactiv?

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:

Librăriile reactive din ecosistemul Java

Î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ă:

Scenariu în lumea reală. Crearea unui serviciu reactiv REST folosind

Java, Spring WebFlux și Spring Data R2DBC

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.

Adăugarea dependințelor

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'
}

Definirea modelelor

Î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;
}

Definirea unui repository reactiv pentru apeluri la baza de date

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);
}

Serviciul Reactiv

Î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()));
}

Expunerea unui endpoint în mod reactiv

Î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()));
}

Event-loop vs. Thread per request. Când este important să devenim reactivi?

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".

Număr mic de utilizatori concomitenți. Operații computațional intensive

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ăr mare de utilizatori concomitenți. Operații I/O intensive

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

Viziune de ansamblu

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

Concluzii

Î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.

Gânduri de final

  1. Programarea reactivă este interesantă. Implică, ca orice altă paradigmă, un mod de gândire inedit.

  2. În acest articol, am făcut doar o introducere de suprafață în lumea reactivității. Pentru aprofundare, cartea [2] este o sursă bună.

  3. 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ă.

  4. Î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.

  5. 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.

  6. Akka este un toolkit atât de complex pentru aplicații distribuite asincrone, încât ar merita un articol separat doar pentru el, dar un loc bun de început este [5].

Referințe

  1. J. Bonér, D. Farley, R. Kuhn, and M. Thompson, "The reactive manifesto",

  2. 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.

  3. Reactive programming guide

  4. Spring webflux under the hood

  5. Akka introduction

NUMĂRUL 149 - Development with AI

Sponsori

  • Accenture
  • BT Code Crafters
  • Accesa
  • Bosch
  • Betfair
  • MHP
  • BoatyardX
  • .msg systems
  • P3 group
  • Ing Hubs
  • Cognizant Softvision
  • Colors in projects