Dans un article précédent, sur Spring Data JDBC, nous avons vu qu'il est possible d'exploiter une couche d'abstraction plus légère que JPA pour développer des applications relativement simples. Nous allons à présent voir s'il est possible d'en faire autant en mode réactif, et comment l'exposer en tant qu'API REST avec Spring.
Le web “réactif” (parfois appelé “web fonctionnel”) est non-bloquant, asynchrone et piloté par les événements. Spring 5 s’appuie sur le serveur d’applications Netty qui correspond à ces contraintes, et sur des clients HTTP “réactifs”, voire WebSocket, pour dialoguer. Cette architecture est plus efficace lorsque les dépendances auprès d’autres services ou de ressources externes sont nombreuses et les accès potentiellement dégradés.
Le module Spring WebFlux s’appuie lui-même sur le Project Reactor, qui fournit une implémentation des Reactive Streams basée sur leur interface Publisher<>
(les autres concepts de cet ordre sont Subscriber
, Subscription
et Processor
). Ils sont réutilisables (contrairement aux streams de Java 8), et prévus pour recevoir tout type de données en entrée, sans limite de durée, ouvrant la porte à la maîtrise de flux infinis.
Les streams du Project Reactor sont matérialisés par les classes Flux<>
et Mono<>
, qui prennent respectivement en charge des éléments multiples et isolés. Elles s’apparentent d’une part à la hiérarchie de classes issue de l’interface Collection<>
(Collections Framework), et d’autre part à la classe Optional<>
. Et elles implémentent toutes l'interface Publisher<>
.
Bâtir une couche d'accès aux données réactive
Comme les caractéristiques du flux doivent être prises en charge dès la source, nous allons commencer par mettre en place une couche d’accès aux données qui soit non-bloquante. Et justement, le projet Spring Data a évolué pour proposer cette couche réactive, à partir de l’interface ReactiveCrudRepository
. Le type de retour des fonctions DAO est naturellement passé à Flux et Mono, que vous pouvez exploiter dans vos propres méthodes de requêtes implicites.
NB: sachez qu’un Mono
ne peut pas contenir de valeur nulle (on utilise Mono#empty
).
Le projet R2DBC (Reactive Relational Database Connectivity)
Pour avancer, vous devrez choisir une couche de persistance adéquate en fonction des disponibilités du marché. Ce sont d’abord des bases de données NoSQL qui se sont révélées compatibles avec ces besoins, comme les systèmes MongoDB, Couchbase et Cassandra, ou encore Redis (in-memory data grid). Cependant le projet R2DBC propose des solutions réactives relationnelles, pour ce que cela vaut, compte tenu des contraintes transactionnelles et de l’architecture de ces systèmes. Pour l’instant ce sont essentiellement les bases H2, MariaDB, Microsoft SQL Server, MySQL et PostgreSQL qui sont gérées. Le projet cherche également à influencer les initiatives ADBA et Asynchronous Database Connectivity in Java (ADBCJ).
Nous allons donc créer un projet Spring Boot (vous utilisez bien le Spring Initializr, n'est-ce pas ?) et opter pour des dépendances vers Spring Data R2DBC et H2 Database :
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.3</version> </parent> ... <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-r2dbc</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.r2dbc</groupId> <artifactId>r2dbc-h2</artifactId> <scope>runtime</scope> </dependency>
Comme nous allons le voir, le projet Spring Data R2DBC propose des repositories réactifs et un template réactif d’accès aux données, dont la classe se nomme DatabaseClient
et qui s’appuie elle-même sur un driver R2DBC dédié à la base de données choisie. Comme avec JDBC, l’activation de la couche d’accès aux données peut être contrôlée par annotation, et le script schema.sql
doit correspondre à la base de données choisie :
En théorie, les entités JDBC n’ont pas à évoluer pour R2DBC. Et dans un premier temps, nous allons simplement adapter les repositories pour bénéficier de l’API réactive :
Nous allons pouvoir tester immédiatement ces fonctionnalités :
Remarque : nous avions ajouté des requêtes DROP TABLE
au script SQL de construction du schéma pour pouvoir jouer différentes méthodes dans ce test d’intégration.
L’interface StepVerifier
permet à la fois de définir les vérifications à effectuer sur les données reçues de manière asynchrone, et de faire remonter les exceptions correspondantes dans le thread principal afin que JUnit se comporte normalement.
Gestion de transactions avec R2DBC
L’annotation @Transactional
a toujours cours avec une couche d’accès aux données réactives avec R2DBC et la fonctionnalité peut être activée à tout moment :
Exploitation du cycle de vie des entités réactives
Malheureusement pour nous… les problèmes vont commencer ! Dans le meilleur des cas, nous constaterions que la relation JDBC n’a pas été suivie ; mais en fait, d’autres erreurs masqueraient le problème de toute façon. R2DBC ne gère pas (encore) les jointures relationnelles du SQL (un comble !), et surtout il ne supporte pas non plus les propriétés basées sur les collections (prends ça dans les dents, PostgreSQL !). C’est pourquoi nous allons devoir finalement reprendre la structure de nos entités et gérer leurs relations, si nous voulons bénéficier quand même d’une couche d’accès aux données réactive avec JDBC.
Voici un test minimal que nous voudrions voir passer à notre modèle de données :
Mais pour cela, il va falloir nous appuyer sur le cycle de vie des entités pour charger et enregistrer les adresses lorsque nous manipulons des personnes… Mais d’abord nous devons modifier ces entités pour les adapter au besoin :
Le véritable changement consiste à sortir la relation multiple de la gestion de R2DBC. Pour lui, la table PERSONS_ADDRESS
n’existera tout simplement pas.
Comme on l’a dit, les observateurs fournis par R2DBC couvrent le chargement et la persistance des données pour les entités que nous avons définies en Java ; mais leur suppression n’est pas (encore ?) prise en charge. Alors voici ce que nous pouvons faire pour gérer nous-mêmes la relation many-to-many :
D'abord, nous avons ajouté une méthode pour exploiter la relation entre les deux entités, via leur table de jointure. Et lorsque nous avons injecté ce repository dans l’observateur de chargement, nous avons déclaré la dépendance en mode “lazy” ; car cet observateur va lui-même être injecté dans le template de R2DBC (DatabaseClient), qui à son tour sera injecté au repository. Et par défaut cette situation de dépendance circulaire n’est pas tolérée par Spring.
L’enregistrement va nous donner un peu plus de fil à retordre. Nous avons décidé de procéder comme l’annotation @ElementCollection
de JPA et de procéder en supprimant toutes les relations concernées avant de les recréer une à une. Bien sûr, cela implique d’exiger la présence d’une transaction, pour se couvrir, et d'effacer ensuite à la main les orphelins potentiels dans la base de données :
Étant donné que la table que nous manipulons ici ne fait pas l’objet d’une implémentation en tant qu’entité, nous utilisons directement le template R2DBC et pas un repository. Il y a d’abord une requête de suppression des relations pour la personne qui vient d’être mise à jour, et ensuite une série de requêtes exécutées en batch pour recréer les relations correspondant à l’objet courant, le cas échéant. La syntaxe de ces requêtes est différente, car l’outil Statement
(qui sert à l’exécution des scripts SCHEMA
et DATA
) se situe “en-dessous” du template DatabaseClient
.
Comme on le voit, l'imbrication des opérations et la syntaxe "fluent" n’aident pas forcément à la compréhension de l’ensemble… Pour exécuter l’intégralité des commandes avant de sortir de la fonction, nous donc attendu (#last) et remplacé le compteur de lignes modifiées (toujours égal à 1) de la dernière opération par l’entité qui doit remonter vers le template.
Notre relation many-to-many est désormais prise en charge, mais nous devons encore nous charger de supprimer les adresses orphelines qui sont susceptibles de rester. En effet, on a supprimé toutes les lignes, puis on a seulement recréé celles qui sont figurées par la présence d'éléments dans la collection porteuse. Avant de nous en occuper, on va ajouter une relation one-to-many à notre modèle, histoire de faire bonne figure :
À présent, nous pouvons prévoir un nettoyage en tâche de fond, à condition de nous assurer qu’il soit toujours effectué après le passage des autres observateurs :
Malheureusement, comme il n’y a pas de callback pour la suppression, notre mécanique restera bancale. La table de jointure ne sera pas réellement propre après une suppression de personne ; mais elle le deviendra lors d’une prochaine opération de mise à jour de n’importe quelle entité de cette nature.
Pour la beauté du geste, nous ajoutons un système de recyclage des adresses, histoire que notre base de données ne s’encombre pas inutilement et que la relation one-to-many soit optimale. Et c’est aussi l'occasion d'utiliser l’extraction des données par l’exemple fournie par Spring Data et à laquelle on ne pense pas forcément :
Par souci d’exhaustivité, on peut signaler l’existence d’un module nommé lc-spring-data-r2dbc, dont les dernières mises à jour ne dataient que de quelques mois au moment où nous écrivions ces lignes. Il s’agit d’une proposition (sans gestion de cache, ni de lazy loading ou write behind) de prise en charge des aspects relationnels pour R2DBC qui est peut-être intéressante, mais nous ne l’avons pas essayée. Vous trouverez toutes les informations à l’adresse suivante :
https://github.com/lecousin/lc-spring-data-r2dbc
Du côté de JPA (et oui, quand même !) le projet Hibernate Reactive reste actif mais n’est pas à notre connaissance intégré à Spring Data. Voici où aller pour en apprendre plus :
https://github.com/hibernate/hibernate-reactive
Exposer et consommer une API web réactive
Bien… Nous sommes à présent en possession d’une couche d’accès aux données reactive à peu près utilisable, au prix d’un effort conséquent tout de même. C’est pourquoi nous allons maintenant remplacer la dépendance à Spring MVC et opter pour WebFlux ; cependant sachez que les deux pourraient cohabiter sans problème :
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>
La première conséquence de ce changement est que le serveur d’applications par défaut passera de Tomcat à Netty. L’Actuator, quant à lui, s'adaptera automatiquement.
Les instruments fournis, destinés à rendre les applications réactives, concernent à la fois le côté serveur comme nous allons le voir, mais également le côté client. Depuis Spring framework version 5, le RestTemplate
a donc son équivalent réactif : WebClient
.
Évidemment, le changement de paradigme a un impact important sur le contrat des DAO, et nous devons donc adapter les services et les contrôleurs qui en dépendent à cette évolution. Le seul vrai changement étant que la classe ModelAndView
a été remplacée par la classe Model
qui serait injectée en paramètre au lieu d’être retournée dans un contexte d’application web. Enfin, la gestion des exceptions est différente, car l’architecture multi-thread ne permet pas de les faire remonter naturellement.
Nous allons d’abord remplir le contrat CREATE-READ-DELETE pour les personnes :
Remarque : selon les comportements constatés du DAO, nous gérons nous-mêmes si besoin le cas NOT FOUND pour assurer l'homogénéité de l'API.
Voici, en guise d’opération UPDATE, comment gérer la résidence des personnes (one-to-many) :
Voici à présent comment gérer les adresses de bureau (many-to-many) :
Et enfin, voici la façon de gérer les exceptions directement dans le contrôleur (rien de neuf) :
Encore une fois, la syntaxe est parfois difficile à comprendre lorsque plusieurs opérations doivent être enchaînées ; mais c’est le prix à payer pour garder la main sur l’exécution réactive, par rapport à la programmation procédurale. Malgré tout, grâce à la puissance de Spring Data et aux observateurs du cycle de vie des entités qui sont externalisés, on obtient une approche assez directe des fonctions souhaitées. L’API est désormais prête à partir en production !
Consommation des services d’une API réactive
Comme nous l’avons déjà dit, du point de vue de l’implémentation, la seule chose notable est le type de retour des méthodes endpoint, qui adoptent le typage Mono<>
ou Flux<>
du Project Reactor. Mais les annotations de mapping HTTP restent les mêmes. À défaut de ce typage, il est nécessaire de spécifier explicitement les routeurs réactifs du service.
Voici un exemple de consommation réactive par un client que nous pourrions créer :
Sachez que les streams streams (ou flux) proposent une méthode #subscribe
au lieu de #forEach
, en guise de visitor pattern. Ils disposent également de mécanismes de résilience intégrée qui sont fort complets :
D’autre part, sachez qu’on peut facilement envelopper le Spring WebClient avec Feign pour implémenter très rapidement des clients réactifs personnalisés.
Tests spécifiques de la réactivité des API
Une API existe qui est destinée à tester le Project Reactor lui-même :
<dependency> <groupId>io.projectreactor</groupId> <artifactId>reactor-test</artifactId> <scope>test</scope> </dependency>
Ces outils permettent de tester des scenarii pas-à-pas (StepVerifier
), et offrent la possibilité de prendre le contrôle sur l'enchaînement des événements (virtual time).
On n’est pas bien, là ?... On pourrait continuer comme ça pendant des heures (la version complète de notre test d’API ne couvre le code qu’à 95%, parce que nous avons été un peu laxistes avec les annotations Lombok en qui nous avons confiance) !
Avant de passer à la suite, on doit signaler l'existence d’une annotation @WebFluxTest
, qui est bien sûr l’équivalent reactive de @WebMvcTest
, pour tester un contrôleur WebFlux à la fois, en bouchonnant éventuellement ses dépendances.
Pour conclure sur ce chapitre d’implémentation des API web réactives, nous vous signalons le projet suivant, qui montre comment exploiter WebFlux avec Spring Data JPA :
https://github.com/rxonda/webflux-with-jpa
Superviser une application web réactive
Désormais les nombreux endpoints de l’Actuator sont regroupés par défaut sous un chemin commun /actuator
. Ils sont si nombreux que la plupart d’entre eux sont désactivés par défaut, afin d’économiser les ressources. Une propriété est prévue pour les activer tous à la fois ou en partie, via une liste et des wildcards :
management.endpoints.web.exposure.include=*
Les endpoints peuvent être étendus très souplement, grâce à la nouvelle annotation @EndpointExtension
et à ses spécialisations, @EndpointWebExtension
et @EndpointJmxExtension
, qui sont à la fois compatibles avec Spring MVC et WebFlux.
D'autre part il est également possible de créer ses propres endpoints à partir de “rien” avec des annotations comme @Endpoint
et @Selector
.
Concernant le endpoint /health
en particulier, notez qu’une nouvelle interface réactive nommée ReactiveHealthIndicator
a été ajoutée au système pour l’implémentation de vos tests de vie. Des mécanismes sont proposés dans la foulée, pour les catégoriser.
Écouter le cycle de vie des endpoints réactifs
De la même façon que le RestTemplate
de Spring MVC supporte la mise en place d’interceptors (ClientHttpRequestInterceptor
), WebFlux propose lui des filters (ExchangeFilterFunction
) pour auditer les échanges avec les services distants.
Conclusions
Bon ! On a bien travaillé avec R2BC. Un peu en mode DIY quand même... Certes. Est-ce que c'est un projet intéressant pour une exploitation professionnelle ? Certainement pas à ce niveau de maturité ; on ne s'imagine pas implémenter (ou maintenir) l'ORM complet d'un projet contenant des dizaines, voire des centaines d'entités. Et l'absence de support de l'événement DELETE dans la couche de persistence est probablement rédhibitoire dans le contexte d'un trafic important. Nous n'avons d'ailleurs pas encore pris le temps de mesurer nous-mêmes les performances de notre exemple, ne serait-ce que pour le comparer notamment à son cousin JDBC (ndlr : ajouter à la liste des choses à faire).
Un ultime désavantage à noter pour le moment : il n'est pas encore possible de monter des tests d’intégration transactionnels, faute de PlatformTransactionManager
. C'est la raison pour laquelle nous avons privilégié des tests fonctionnels pointés directement sur l'API ; étant donné que nous avions imposé la présence d'une transaction dans l'implantation des callbacks. Cela ne change rien à la couverture de code, c'est juste qu'ils sont moins découplés qu'on pourrait le souhaiter idéalement.
Cependant, encore une fois, la compréhension des mécanismes internes à un niveau d'abstraction suffisamment bas est souvent bénéfique. Notamment pour utiliser la technologie Reactive qui constitue un changement de paradigme de programmation. Et aussi pour se lancer enfin vers la solution Spring WebFlux qui, en pratique, est réputée pour offrir de meilleures performances que Spring Servlet, sur certains cas d'utilisation comme les API web.