Le projet Spring Data a une envergure rarement égalée au sein de la galaxie Spring IO. On parle même de "parapluie" pour le décrire. En s'appuyant sur les principes de Spring Integration, son ambition est de couvrir la persistance des données au sens large : JPA évidemment, R2DBC, NoSQL, cache, SEO, LDAP... Et j'en passe et des meilleures ! Il est pourtant un module de bas niveau qui, quand il n'est pas simplement ignoré, donne lieu à des retours d'expérience disons... mitigés ; mais sans doute à tort.
Tout d'abord, revenons rapidement sur les fonctionnalités avantageuses promises par la documentation de Spring Data :
* Référentiel puissant et abstractions de mappage d'objets personnalisées
* Dérivation de requête dynamique à partir des noms de méthode du référentiel - J'adore !
* Classes de base de domaine d'implémentation fournissant des propriétés de base
* Prise en charge de l'audit transparent (création, dernière modification) - Indispensable !
* Intégration avancée avec les contrôleurs Spring MVC, etc.
Nous allons tout de suite créer un projet Spring Data JDBC avec Spring Boot (et Project Lombok, aussi ; parce que j'aime bien, c'est efficace ; mais il faudra l'intégrer à votre IDE préféré pour que la génération soit bien prise en charge) :
Version de Java : 11 à minima
Maven 3 ou Gradle pour la compilation du projet
Parent du projet : org.springframework.boot:spring-boot-starter-parent:2.5.6
Dépendances : org.projectlombok:lombok
org.hsqldb:hsqldb
org.springframework.boot:spring-boot-starter-data-jdbc
Notez que le module “Spring JDBC” (qui propose notamment une classe JdbcTemplate fameuse) n’est pas la même chose que le module “Spring Data JDBC”. Cependant ce dernier embarque le premier, donc nous sommes prêts à commencer.
Si vous avez utilisé le Spring Initializr (c'est bien ce que vous avez fait, n'est-ce pas ?..) alors une classe applicative - @SpringBootApplication - et une classe de test - il y a @SpringBootTest, bien sûr, mais l'annotation @DataJdbcTest conviendra mieux à notre contexte - sont dores et déjà disponibles. Nous avons intérêt à préparer tout de suite le niveau de journalisation, dans le fichier application.properties, afin d'obtenir des traces détaillées durant nos développements :
Relations one-to-one
Hop là ! Mettons tout de suite le pied à l'étrier et voyons comment implémenter un premier système d'information avec Spring Data JDBC. D'abord, créons un modèle dans la base de données HyperSQL embarquée, avec un fichier schema.sql à placer dans les ressources du projet :
create table PERSONS (
id bigint IDENTITY PRIMARY KEY,
first_name varchar(30),
last_name varchar(30) NOT NULL,
birth_date date NOT NULL,
UNIQUE(first_name, last_name, birth_date)
);
create table ADDRESS (
id bigint IDENTITY PRIMARY KEY,
person bigint FOREIGN KEY REFERENCES PERSONS(id),
street_number int NOT NULL,
street varchar(30) NOT NULL,
city varchar(30) NOT NULL,
state varchar(30),
zip varchar(30) NOT NULL,
country varchar(30) NOT NULL
);
Voilà, nous pouvons maintenant lui associer les entités Java correspondantes, ainsi qu'une couche d'accès aux données :
Le repository Spring Data, pour JDBC comme pour les autres technologies, se présente sous la forme d’une interface publique étendant l’interface CrudRepository. Les méthodes CRUD classiques et quelques finders (ex: by id, all...) seront inclus automatiquement, en plus de celles que nous avons créées. Quoi qu'il en soit, rien de bien compliqué jusqu'ici. Ajoutons simplement quelques données en base, avec cette fois-ci le fichier data.sql :
insert into persons(first_name, last_name, birth_date) values('foo0', 'bar0', '2000-01-01');
insert into persons(first_name, last_name, birth_date) values('foo1', 'bar1', '2001-01-01');
insert into persons(first_name, last_name, birth_date) values('foo2', 'bar2', '2002-01-01');
insert into address(person, street_number, street, city, zip, country) values(0, 10, 'Foobar street', 'Foocity', '11111', 'Fooland');
Spring Data JDBC ne gère que les relations one-to-one et one-to-many unidirectionnelles. La relation ci-dessus, qui est portée par la table ADDRESS, permet d’affecter une ou plusieurs adresses à une personne. Et par convention, il est supposé que le champ qui porte la relation en base ait le nom de l’entité Java visée. Une petite classe de test va nous amener à y voir clair :
Nous avions affecté une adresse à une des personnes présentes. Et lorsque nous lançons le test, les traces de JDBC nous renseignent sur ce qu’il se passe pour la méthode #findAll :
Executing prepared SQL statement [SELECT persons.id AS id, persons.first_name AS first_name, persons.last_name AS last_name, persons.birth_date AS birth_date, address.id AS address_id, address.street_number AS address_street_number, address.street AS address_street, address.city AS address_city, address.state AS address_state, address.zip AS address_zip, address.country AS address_country FROM persons LEFT OUTER JOIN address AS address ON address.person = persons.id]
D'abord, observons la jointure qui se base bien sur la convention de nommage par défaut que nous avons respectée : address.person = persons.id
Ensuite, remarquons que des alias sont mis en place pour tous les champs et que les champs correspondant à la table ADDRESS sont préfixés par le nom de la propriété Java. Mais comme nous n’avons pas effectué ces changements du côté de notre méthode #findByLastName dans le repository, son invocation s'avérera très décevante :
Persons by name: [Person(id=0, firstName=foo0, lastName=bar0, birthDate=2000-01-01, address=null)]
En effet, la mécanique du mapping relationnel en vigueur attend les champs nécessaires à l’initialisation des agrégats dans le ResultSet… Ce que nous pouvons fournir afin de rétablir la situation, s’agissant de notre cas simple :
Persons by name: [Person(id=0, firstName=foo0, lastName=bar0, birthDate=2000-01-01, address=Address(id=0, number=10, street=Foobar street, city=Foocity, state=null, zip=11111, country=Fooland))]
REM: le #toString bien propret de ces entités nous est offert par Lombok, que nous avons très bien fait d'utiliser, je trouve :-)
Cette requête SQL respecte les conventions qui vont permettre à notre extraction de fonctionner. Cependant on a du mal à imaginer comment on pourra maintenir de telles requêtes dans un exemple réel, où les associations seront multiples, voire circulaires… Il vaudra mieux, sans doute, envisager les clefs étrangères comme de simples propriétés, et extraire les données dont nous aurons besoin manuellement, à l’aide de repositories (DAO) dédiés ! C'est un peu décevant, certes.
Dérivation de requêtes SQL à partir des noms des méthodes (ah ben quand même !)
Vous pouvez reprendre à présent reprendre les DAO définis ci-dessus et en retirer les annotations @Query pour les requêtes de type SELECT (le DELETE n'est pas encore correctement géré). C'est magique ; et encore... nous n'avons pas exploité toutes les possibilités offertes, comme la pagination et le tri !
Relations one-to-many
Ajoutons à présent une seconde adresse à la même personne dans data.sql :
insert into address(person, street_number, street, city, zip, country) values(0, 20, 'Foobar street', 'Foocity', '22222', 'Fooland');
L’extraction des données par JDBC se comporte conformément à la jointure SQL, en faisant remonter autant d’adresses qu’il y en a en base, ce qui pose évidemment un problème si nous souhaitons procéder à des extraction unitaires, comme avec la méthode #findById du repository...
org.springframework.dao.IncorrectResultSizeDataAccessException: Incorrect result size: expected 1, actual 2
Nous devons donc revoir notre entité, afin d’exprimer une relation one-to-many au lieu d’une relation one-to-one :
… Et obtenir alors un résultat plus exploitable :
Persons by name: [
Person(id=0, firstName=foo0, lastName=bar0, birthDate=2000-01-01, address=[Address(id=0, number=10, street=Foobar street, city=Foocity, state=null, zip=11111, country=Fooland), Address(id=1, number=20, street=Foobar street, city=Foocity, state=null, zip=22222, country=Fooland)]),
Person(id=0, firstName=foo0, lastName=bar0, birthDate=2000-01-01, address=[Address(id=0, number=10, street=Foobar street, city=Foocity, state=null, zip=11111, country=Fooland), Address(id=1, number=20, street=Foobar street, city=Foocity, state=null, zip=22222, country=Fooland)])]
C’est mieux du point de vue de l’exécution, mais ça ne va toujours pas du côté des données ! Les enregistrements sont dédoublés, même si chacun d’eux contient bien les deux adresses attitrées.
En fait, nous constatons dans les traces JDBC que la façon dont les données ont été extraites a radicalement changée : au lieu d’une seule requête avec jointure, les données ont été collectées en plusieurs fois (d’abord dans la table PERSONS, puis autant de fois que nécessaire dans la table ADDRESS). Nos entités ne semblent plus considérées comme une composition (relation forte), mais comme un agrégat (relation faible) ! En fait c’est assez logique, après tout, et nous allons donc pouvoir revenir sur notre repository pour obtenir enfin le résultat souhaité :
Bien. La requête a de nouveau l’air “normal”. Et ce long détour nous aura permis de comprendre comment corréler les relations en base avec les entités Spring Data JDBC, et comment adresser le cas “délicat” des relations… 1-à-1 ! Et du coup, ça vaut vraiment le coup maintenant de se laisser porter par la dérivation des noms de méthode, non ? Retirez l'annotation @Query et ajoutez donc un paramètre org.springframework.data.domain.Pageable (voir aussi le helper PageRequest) à ses arguments.
Le plus dur est fait, d’une certaine manière. Même si les relations qui sont gérables avec JDBC sont moins évoluées qu’avec JPA, vous serez désormais capables de vous en sortir dans la majorité des cas ; au prix parfois de plusieurs requêtes successives pour constituer des échantillons de données aux relations complexes (many-to-many).
Vous aurez peut-être noté que, pour l’instant, nous avons omis de représenter le champ de jointure (ADDRESS.person) dans l’entité Java correspondante. Grâce à la convention indiquée, nous n’en avons pas eu besoin. Mais alors comment diable lier et enregistrer des adresses dans le système à partir de code Java ?..
Gestion de la persistance JDBC des relations SQL
Nous allons à présent voir comment créer des entités programmatiquement et gérer leur persistance, en créant le DAO des adresses et un nouveau test assez touffu à cet effet :
Si nous faisons tant de simagrées ici, c’est que dans les versions antérieures de Spring Data JDBC, après l’enregistrement de l’entité en base, la liste des adresses ne contenait pas une, mais deux références… à la même instance en mémoire, qui plus était !
Cela doit nous surprendre, puisqu'en Java les collections Set sont supposées ne contenir aucun doublon selon la méthode #hashCode… Or un bug dans la gestion des entités venait ajouter et non remplacer l’entité stockée en base (id!=null) à celle déjà présente (id==null). Le hashcode calculé étant différent, rien ne s’opposait à cette opération.
Bien sûr, il est possible d’éviter ce problème en enregistrant les entités AVANT de les associer à d’autres entités (remarquez que cette contrainte est imposée par JPA) ; ou alors en excluant du calcul du hashcode les propriétés modifiées par la base lors de l’insertion :
Quoi qu’il en soit, ce test montre que les relations du modèle sont bien prises en compte à l’enregistrement des données… mais cela sera aussi le cas lors de leur suppression (pas mal, pour une relation dite faible) ! En effet, c’est la philosophie orientée value object qui prévaut dans le module. Il est important de remarquer que l'instance construite localement est recyclée entre les appels à #save (malgré sa valeur de retour et contrairement à JPA encore une fois). Un autre test (au lieu de la section “remove” précédente) peut vous aider à garder ces concepts sensibles en mémoire :
Si vous avez besoin de supprimer des enregistrements sans toucher à leurs sous-parties (typiquement dans le cadre d’une relation many-to-many), alors avec Spring Data JDBC vous devrez les référencer par leur clef primaire uniquement, et non par composition.
De la même façon, vous ne pourrez pas créer de copies d’enregistrements composés en vous contentant de mettre à NULL la clé de l’objet porteur, c’est-à-dire sans réinitialiser les clés des objets en relation avec lui. Les requêtes seront de type INSERT pour toutes les parties de l’agrégat, et les identifiants seront donc en conflit avec l’index de la table.
Vous ne pourrez pas non plus réutiliser une adresse d’une relation vers une autre ; souvenez-vous, la relation est portée par la table ADDRESS. Encore une fois, si vous souhaitez utiliser une relation many-to-many, vous devrez la référencer par une clef primaire et non une entité. Cela vous forcera donc à effectuer les enregistrements et les extractions séparément.
Relations many-to-many ?.. donc pas vraiment
Une conclusion importante doit être tirée de ce que nous avons appris dans les sections précédentes : Spring Data JDBC n’est pas prévu pour gérer des relations many-to-many. Cela peut être perturbant au premier abord, car on imagine mal comment se passer de ce type de relations en pratique !.. Tant que l’on ne prend pas en compte l’idée que Spring Data JDBC s’appuie entièrement sur le concept du “Aggregate Pattern”.
Cette philosophie issue du Domain-Driven Design postule que les entités sont des “value objects” et que tout ce qui est accessible directement depuis un objet fait PARTIE de cet objet (de sont état, autrement dit). Ainsi avec Spring Data JDBC, on ne crée pas un DAO pour une entité mais pour un aggregate (aka single unit objects cluster).
Leurs instances sont toujours enregistrées et chargées ensemble, à partir de leur aggregate root. Tout ce qui n’est pas en relation exclusive avec l’agrégat doit donc être manipulé à part, avec un AUTRE DAO. C’est d’ailleurs la condition sine qua non pour arriver à manipuler un schéma relationnel complet, sans avoir recours à du "DIY lazy loading" !
Voici le nouveau schéma avec une relation many-to-many entre PERSONS et ADDRESS :
NB: pensez à retirer les insertions d’adresse dans data.sql
Évidemment, nous souhaitons éviter d’avoir à extraire les informations liées aux objets au sein du code métier. Et nous ne voudrions pas non plus développer une surcouche de service pour gérer les relations entre les données, ce qui est le rôle de la couche d’accès aux données par définition… Voici donc une implémentation de ce mapping, où nous allons utiliser une notion de référence interne afin de simplifier au maximum l’API proposée aux couches supérieures :
Spring Data JDBC va gérer pour nous la moitié de la relation qui est portée par l’entité que nous aurons choisie (ici la classe Person). Nous n’avons besoin d’indiquer que la contrepartie de cette relation (l’ID d’une adresse), et nous pourrions également la compléter par d’autres informations (par exemple le pays) que nous aurions alors à disposition, directement dans la référence interne, au lieu de devoir les charger à part.
Comme on le voit, nous avons implanté quelques méthodes utiles directement dans le modèle. Mais nous devons également proposer un moyen propre d’extraire les données de la relation, par exemple grâce à la fusion de DAO prévue par Spring Data :
Il ne restera alors plus qu’à mettre en oeuvre la relation many-to-many :
Dans la console, vous devriez trouver ce genre de messages :
- NEW PERSON: Person(id=3, firstName=foo, lastName=bar, birthDate=2019-02-18, addresses=[Person.AddressRef(address=0)])
- PERSONAL ADDRESS: [Address(id=0, number=0, street=the street, city=the city, state=null, zip=xxxxx, country=the country)]
Aller jusqu'au bout de la logique des agrégats
Les exemples proposés dans la section précédente ont permis de s’accommoder de cette philosophie DDD. Cependant, il serait sans doute plus avisé d’aller au bout des principes proposés, afin de s’éviter des ennuis à long terme. Le fait que l’on doive préserver le hashcode des entités ne serait pas un problème avec des value objects réels, qui sont en principe des objets immuables. Si nous adoptions cette stratégie, voici un exemple de ce que pourrait/devrait être notre code :
Grâce à Lombok et à son annotation @Value (bien pratique, pour obtenir un value object), nous n’avons pas eu besoin de changer grand chose pour obtenir des classes dont tous les champs sont déclarés final et donc dépourvus de setter. Par cohérence, nous avons également employé l’annotation @Immutable fournie par Spring Data, censée entériner ce mode particulier d’utilisation des entités. Si vous utilisez une version supérieure à Java 16, vous pourriez en profiter pour utiliser la version finalisée des record classes qui adressent justement le concept des agrégats au niveau du langage !
À la différence de la mise en œuvre précédente, nous sommes désormais forcés de réassigner la variable du aggregate root à chaque enregistrement ; étant donné que l’entité immuable fournie ne peut être modifiée par la couche d’accès aux données, elle est systématiquement régénérée sous la forme d’une nouvelle instance en mémoire. Ce qui nous montre bien l'intention de départ du cadriciel.
Conclusions
Cette visite guidée de Spring Data JDBC nous a montré que ce module repose sur une philosophie différente de JPA, en l'occurrence héritée du DDD ; en espérant que cette compréhension permette à chacun de l'exploiter sereinement désormais. Ce module reste puissant pour ceux qui ne souhaitent pas utiliser une technologie trop avancée, si cela existe. Sachez par ailleurs que Spring (Data) JDBC est bien sûr compatible avec la gestion des transactions relationnelles de Spring, et que des fonctions d'audit sont offertes (voir @EnableJdbcAuditing). Mais à chaque jour suffit sa peine.
Un petit retour d'expérience, quand même : la gestion des relations @OneToMany est assez mauvaise quand on doit traiter des accès concurrentiels. Et avec des volumétries de données importantes, on devra recourir à du lazy loading si l'on ne veut pas voir les performances s'effondrer. En effet, la gestion basée sur de simples collections de type Set n'est pas optimale et oblige souvent à passer par des requêtes @Query. [Merci à Cédric Magne sur ces points]
Et afin d'optimiser le codage, nous avons tiré parti des avantages énormes de la librairie Lombok, même si celle-ci tend à faire florès au fur et à mesure de l'évolution du langage Java. Son utilisation est peut-être notre vraie recommandation, au fond, car il est probable que la plupart des projets continueront à opter pour JPA par défaut. M'enfin, sachez qu'il est tout à fait possible d'utiliser différents modules Spring Data dans un même projet, histoire de réunir le meilleur de plusieurs mondes.
La série d'articles suivants (en anglais) vous permettra d'aller plus loin :
Bonne continuation !